diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index e94f8140cc0..00000000000 --- a/.browserslistrc +++ /dev/null @@ -1 +0,0 @@ -defaults diff --git a/.erb_lint.yml b/.erb_lint.yml new file mode 100644 index 00000000000..57498cc2165 --- /dev/null +++ b/.erb_lint.yml @@ -0,0 +1,9 @@ +--- +exclude: + - 'vendor/bundle/**/*' +EnableDefaultLinters: true +linters: + SpaceAroundErbTag: + enabled: true + ExtraNewline: + enabled: true diff --git a/.gitattributes b/.gitattributes index 0084e320567..aa99e98b024 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ -# reclassifies `.html` files as Ruby so the repo language is correct: *.html linguist-language=Ruby +app/assets/stylesheets/themes/* linguist-vendored=true +app/assets/javascripts/application.js linguist-vendored=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1e675e71d1b..871a07ff60a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,15 +26,30 @@ Steps to reproduce the behavior: Where are you running/using Password Pusher? + + - [ ] pwpush.com +- [ ] oss.pwpush.com - [ ] Docker Image - [ ] pwpush - - [ ] custom image -- [ ] Heroku + - [ ] pwpush-public-gateway + - [ ] pwpush-worker + - [ ] Custom image - [ ] Digital Ocean + - [ ] App Platform + - [ ] Kubernetes Service +- [ ] Heroku - [ ] Microsoft Azure + - [ ] App Service + - [ ] Container Instances (ACI) + - [ ] Kubernetes Service (AKS) - [ ] Google Cloud + - [ ] App Engine + - [ ] Cloud Run + - [ ] Kubernetes Engine - [ ] AWS + - [ ] Elastic Container Service (ECS) + - [ ] Kubernetes Service (AKS) - [ ] Source Code - [ ] Other (please specify) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 9a03d605890..c387120fb0d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -21,5 +21,3 @@ assignees: ## 📎 Additional context - - diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 791a5fb5472..4699a88229b 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -19,7 +19,7 @@ categories: labels: [dependencies] template: | - ## What’s Changed + ## :memo: What’s Changed $CHANGES @@ -27,7 +27,24 @@ template: | $CONTRIBUTORS - ## Docker Images + ## :motor_boat: Docker Images Available on Docker Hub: https://hub.docker.com/r/pglombardo/pwpush + + ## :running_man: Run This Version + + ```bash + docker run -d -p 5100:5100 pglombardo/pwpush:$NEXT_PATCH_VERSION + ``` + + ..and go to `http://localhost:5100` + + ## :link: Useful Links + + * [Documentation](https://docs.pwpush.com) + * [Docker Hub](https://hub.docker.com/r/pglombardo/pwpush) + * [GitHub](https://github.com/pglombardo/PasswordPusher) + * [Website](https://pwpush.com) + * [Twitter](https://twitter.com/pwpush) + * [Newsletter](https://buttondown.email/pwpush) diff --git a/.github/workflows/brakeman-analysis.yml b/.github/workflows/brakeman-analysis.yml deleted file mode 100644 index db8b9f37c67..00000000000 --- a/.github/workflows/brakeman-analysis.yml +++ /dev/null @@ -1,35 +0,0 @@ -# This workflow integrates Brakeman with GitHub's Code Scanning feature -# Brakeman is a static analysis security vulnerability scanner for Ruby on Rails applications - -name: Brakeman Scan - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '25 2 * * 6' - -jobs: - brakeman-scan: - name: Brakeman Scan - runs-on: ubuntu-latest - steps: - # Checkout the repository to the GitHub Actions runner - - name: Checkout - uses: actions/checkout@v4 - - # Customize the ruby version depending on your needs - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.0' - - - name: Run brakeman with reviewdog - uses: reviewdog/action-brakeman@v2.7.0 - # env: - # GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - with: - reporter: github-pr-review diff --git a/.github/workflows/brakeman.yml b/.github/workflows/brakeman.yml new file mode 100644 index 00000000000..f3fb790d79c --- /dev/null +++ b/.github/workflows/brakeman.yml @@ -0,0 +1,59 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow integrates Brakeman with GitHub's Code Scanning feature +# Brakeman is a static analysis security vulnerability scanner for Ruby on Rails applications + +name: Brakeman Scan + +on: + push: + branches: [ "master" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + schedule: + - cron: '20 4 * * 4' + +permissions: + contents: read + +jobs: + brakeman-scan: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + name: Brakeman Scan + runs-on: ubuntu-latest + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v4 + + # Customize the ruby version depending on your needs + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Setup Brakeman + env: + BRAKEMAN_VERSION: '6.2.2' + run: | + gem install brakeman --version $BRAKEMAN_VERSION + + # Execute Brakeman CLI and generate a SARIF output with the security issues identified during the analysis + - name: Scan + continue-on-error: true + run: | + brakeman -f sarif -o output.sarif.json . + + # Upload the SARIF file generated in the previous step + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: output.sarif.json diff --git a/.github/workflows/docker-containers.yml b/.github/workflows/docker-containers.yml index 509a385eb95..17f0f95812d 100644 --- a/.github/workflows/docker-containers.yml +++ b/.github/workflows/docker-containers.yml @@ -4,7 +4,6 @@ name: Docker Container Builds on: push: tags: - - "release" - "v*.*.*" workflow_dispatch: @@ -16,19 +15,30 @@ on: default: false schedule: # * is a special character in YAML so you have to quote this string - # Run every day at 5:24 UTC - build 'latest' docker containers + # Run every day at 5:24 UTC - build 'nightly' docker containers - cron: "24 17 * * *" + + pull_request_target: + types: + - labeled + env: DOCKER_PUSH: true jobs: - build: + pwpush-container: + if: github.event.label && github.event.label.name == 'docker' || github.event_name != 'pull_request_target' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + image: tonistiigi/binfmt:master - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -38,15 +48,14 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USERNAME }}/pwpush - flavor: | - latest=false tags: | - type=match,pattern=release - type=schedule,pattern=latest + type=ref,event=pr,format=pr-{{ref}}-docker + type=match,pattern=stable + type=schedule,pattern=nightly type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=semver,pattern=latest + type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }} - name: Login to DockerHub uses: docker/login-action@v3 @@ -55,7 +64,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./containers/docker/Dockerfile platforms: linux/amd64,linux/arm64 @@ -66,3 +75,105 @@ jobs: cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/pwpush:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/pwpush:buildcache,mode=max,ignore-error=${{env.DOCKER_PUSH == 'false'}} + public-gateway-container: + if: github.event.label && github.event.label.name == 'docker' || github.event_name != 'pull_request_target' + needs: pwpush-container + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + fetch-depth: 1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + image: tonistiigi/binfmt:master + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Populate Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USERNAME }}/pwpush-public-gateway + tags: | + type=ref,event=pr,format=pr-{{ref}}-docker + type=match,pattern=stable + type=schedule,pattern=nightly + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + file: ./containers/docker/Dockerfile.public-gateway + platforms: linux/amd64,linux/arm64 + provenance: false + push: true + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/pwpush-public-gateway:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/pwpush-public-gateway:buildcache,mode=max,ignore-error=${{env.DOCKER_PUSH == 'false'}} + + worker-container: + if: github.event.label && github.event.label.name == 'docker' || github.event_name != 'pull_request_target' + needs: pwpush-container + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + fetch-depth: 1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + image: tonistiigi/binfmt:master + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Populate Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USERNAME }}/pwpush-worker + tags: | + type=ref,event=pr,format=pr-{{ref}}-docker + type=match,pattern=stable + type=schedule,pattern=nightly + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + file: ./containers/docker/Dockerfile.worker + platforms: linux/amd64,linux/arm64 + provenance: false + push: true + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/pwpush-worker:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/pwpush-worker:buildcache,mode=max,ignore-error=${{env.DOCKER_PUSH == 'false'}} + diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index 7d50bfd3204..32aef57efaa 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -1,6 +1,6 @@ name: Greetings -on: [pull_request, issues] +on: [pull_request_target, issues] permissions: contents: read diff --git a/.github/workflows/lint-helm.yaml b/.github/workflows/lint-helm.yaml new file mode 100644 index 00000000000..1bf25397421 --- /dev/null +++ b/.github/workflows/lint-helm.yaml @@ -0,0 +1,47 @@ +--- +name: Lint and Test Charts + +on: pull_request + +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.12.1 + + - uses: actions/setup-python@v5 + name: Setup Python + with: + python-version: "3.10" + check-latest: true + + - name: Set up chart-testing + uses: helm/chart-testing-action@v2.7.0 + + - name: Run chart-testing (list-changed) + id: list-changed + run: | + changed=$(ct list-changed --config ./ct.yaml --target-branch ${{ github.event.repository.default_branch }}) + if [[ -n "$changed" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Run chart-testing (lint) + if: steps.list-changed.outputs.changed == 'true' + run: ct lint --config ./ct.yaml --target-branch ${{ github.event.repository.default_branch }} + + - name: Create kind cluster + if: steps.list-changed.outputs.changed == 'true' + uses: helm/kind-action@v1.12.0 + + - name: Run chart-testing (install) + if: steps.list-changed.outputs.changed == 'true' + run: ct install --config ./ct.yaml --target-branch ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 4152be46933..6797360e526 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -2,15 +2,20 @@ name: Release Drafter on: push: - # branches to consider in the event; optional, defaults to all branches: - master +permissions: + contents: read + jobs: update_release_draft: + permissions: + contents: write + pull-requests: write runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@v6.0.0 + - uses: release-drafter/release-drafter@v6 env: - GITHUB_TOKEN: ${{ secrets.RELDRAFTER_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ruby-tests.yml b/.github/workflows/ruby-tests.yml index 381e43243fd..c1273e6c085 100644 --- a/.github/workflows/ruby-tests.yml +++ b/.github/workflows/ruby-tests.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.2'] + ruby-version: ['3.4'] env: BUNDLE_WITHOUT: 'development' @@ -33,10 +33,10 @@ jobs: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - name: Setup Node 14 + - name: Setup Node 20 uses: actions/setup-node@v4 with: - node-version: 14.x + node-version: 20.x - name: Get yarn cache id: yarn-cache @@ -64,7 +64,16 @@ jobs: bundle config path vendor/bundle bundle install --jobs 4 --retry 3 yarn install --frozen-lockfile - - name: Run tests + + - name: Prepare tests run: | bin/rails assets:precompile RAILS_ENV=test + bin/rails db:migrate RAILS_ENV=test + + - name: Run tests + run: | bin/rails test + + - name: Run system tests + run: | + bin/rails test:system \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8b2fd64b0bc..7e7f3334a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -663,5 +663,13 @@ storage/ bin/*.rb /config/credentials/development.key - +/config/credentials/staging.key /config/credentials/production.key + +/containers/docker/mariadb-data/ +/containers/docker/postgres-data/ +/containers/docker/mysql-data/ + +/public/assets/ + + diff --git a/.overcommit.yml b/.overcommit.yml new file mode 100644 index 00000000000..187f4d31149 --- /dev/null +++ b/.overcommit.yml @@ -0,0 +1,77 @@ +# Use this file to configure the Overcommit hooks you wish to use. This will +# extend the default configuration defined in: +# https://github.com/sds/overcommit/blob/main/config/default.yml +# +# At the topmost level of this YAML file is a key representing type of hook +# being run (e.g. pre-commit, commit-msg, etc.). Within each type you can +# customize each hook, such as whether to only run it on certain files (via +# `include`), whether to only display output if it fails (via `quiet`), etc. +# +# For a complete list of hooks, see: +# https://github.com/sds/overcommit/tree/main/lib/overcommit/hook +# +# For a complete list of options that you can use to customize hooks, see: +# https://github.com/sds/overcommit#configuration +# +# Uncomment the following lines to make the configuration take effect. + +verify_signatures: false + +PreCommit: + I18nTasksNormalize: + enabled: true + description: 'Run i18n-tasks normalize on locales' + required_executable: 'i18n-tasks' + include: + - 'config/locales/**/*.yml' + command: ['bundle', 'exec', 'i18n-tasks', 'normalize'] + on_warn: fail # Ensure it stops commit if there's a warning + + RuboCop: + enabled: true + required: true + command: ['bundle', 'exec', 'rubocop', '-f', 'simple'] + + AutoFixTrailingWhitespace: + enabled: true + description: 'Removes trailing whitespace in files' + command: 'sed -i -e "s/[[:space:]]\+$//"' + include: '**/*.{rb,yml}' # Fix for file pattern matching + exclude: ['vendor/**/*'] # You can add more exclusions as needed + + TrailingWhitespace: + enabled: false + exclude: + - '**/db/structure.sql' # Ignore trailing whitespace in generated files + + ErbLint: + enabled: true + required: true + command: ['bundle', 'exec', 'erblint', '--lint-all'] + +# StandardRB: +# enabled: false +# required: true +# command: ['bundle', 'exec', 'standardrb'] +# RustyWind: +# enabled: true +# required: true +# command: ['yarn', 'run', 'rustywind-fix'] + +# PrePush: +# RSpec: +# enabled: true +# required: true +# command: ['bundle', 'exec', 'rspec'] + +#PreCommit: +# RuboCop: +# enabled: true +# on_warn: fail # Treat all warnings as failures +# +#PostCheckout: +# ALL: # Special hook name that customizes all hooks of this type +# quiet: true # Change all post-checkout hooks to only display output on failure +# +# IndexTags: +# enabled: true # Generate a tags file with `ctags` each time HEAD changes diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a5292f46ab..dd573e5002c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v3.4.0 hooks: - id: trailing-whitespace exclude: '\.enc$' @@ -13,11 +13,20 @@ repos: - repo: local hooks: - - id: rubocop - name: Running rubocop - entry: rubocop + - id: erblint + name: ERBLint + entry: erblint language: system - pass_filenames: false - #args: ['-a'] - always_run: true - stages: [pre-commit] + types: [html] + files: \.html\.erb$ + pass_filenames: true + +- repo: local + hooks: + - id: standardrb + name: standardrb + entry: standardrb + language: system + types: [ruby] + files: \.rb$ + pass_filenames: true diff --git a/.rubocop.yml b/.rubocop.yml index a1d3536f433..13e75166aeb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,234 +1,1857 @@ -require: - - rubocop-performance - - rubocop-rails +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } -AllCops: - NewCops: enable - Exclude: - - 'db/migrate/*.rb' - - 'db/schema.rb' +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false -Metrics/ClassLength: - Max: 400 +# https://github.com/standardrb/standard/blob/v1.40.0/config/base.yml +Bundler/DuplicatedGem: + Enabled: true + Include: + - '**/*.gemfile' + - '**/Gemfile' + - '**/gems.rb' -Metrics/MethodLength: - Max: 80 +Bundler/DuplicatedGroup: + Enabled: false -Metrics/CyclomaticComplexity: - Max: 15 +Bundler/GemComment: + Enabled: false -Metrics/PerceivedComplexity: - Max: 15 +Bundler/GemFilename: + Enabled: false -Metrics/AbcSize: - Max: 200 +Bundler/GemVersion: + Enabled: false -Metrics/BlockLength: - Max: 120 +Bundler/InsecureProtocolSource: + Enabled: true + Include: + - '**/*.gemfile' + - '**/Gemfile' + - '**/gems.rb' -Layout/LineLength: - Max: 130 +Bundler/OrderedGems: + Enabled: false -Style/ClassAndModuleChildren: +Gemspec/AddRuntimeDependency: Enabled: false -Style/Documentation: +Gemspec/DependencyVersion: + Enabled: false + +Gemspec/DeprecatedAttributeAssignment: + Enabled: true + +Gemspec/DevelopmentDependencies: + Enabled: false + +Gemspec/DuplicatedAssignment: + Enabled: true + Include: + - '**/*.gemspec' + +Gemspec/OrderedDependencies: + Enabled: false + +Gemspec/RequireMFA: + Enabled: false + +Gemspec/RequiredRubyVersion: + Enabled: false + +Gemspec/RubyVersionGlobalsUsage: + Enabled: false + +Layout/AccessModifierIndentation: + Enabled: true + EnforcedStyle: indent + IndentationWidth: ~ + +Layout/ArgumentAlignment: + Enabled: true + EnforcedStyle: with_fixed_indentation + +Layout/ArrayAlignment: + Enabled: true + EnforcedStyle: with_fixed_indentation + +Layout/AssignmentIndentation: + Enabled: true + IndentationWidth: ~ + +Layout/BeginEndAlignment: + Enabled: true + EnforcedStyleAlignWith: start_of_line + Severity: warning + +Layout/BlockAlignment: + Enabled: true + EnforcedStyleAlignWith: either + +Layout/BlockEndNewline: + Enabled: true + +Layout/CaseIndentation: + Enabled: true + EnforcedStyle: end + +Layout/ClassStructure: + Enabled: false + +Layout/ClosingHeredocIndentation: + Enabled: true + +Layout/ClosingParenthesisIndentation: + Enabled: true + +Layout/CommentIndentation: + Enabled: true + +Layout/ConditionPosition: + Enabled: true + +Layout/DefEndAlignment: + Enabled: true + EnforcedStyleAlignWith: start_of_line + Severity: warning + +Layout/DotPosition: + Enabled: true + EnforcedStyle: leading + +Layout/ElseAlignment: + Enabled: true + +Layout/EmptyComment: + Enabled: true + AllowBorderComment: true + AllowMarginComment: true + +Layout/EmptyLineAfterGuardClause: + Enabled: false + +Layout/EmptyLineAfterMagicComment: + Enabled: true + +Layout/EmptyLineAfterMultilineCondition: + Enabled: false + +Layout/EmptyLineBetweenDefs: + Enabled: true + AllowAdjacentOneLineDefs: false + NumberOfEmptyLines: 1 + +Layout/EmptyLines: + Enabled: true + +Layout/EmptyLinesAroundAccessModifier: + Enabled: true + +Layout/EmptyLinesAroundArguments: + Enabled: true + +Layout/EmptyLinesAroundAttributeAccessor: Enabled: false -Gemspec/DeprecatedAttributeAssignment: # new in 1.30 +Layout/EmptyLinesAroundBeginBody: Enabled: true -Gemspec/DevelopmentDependencies: # new in 1.44 + +Layout/EmptyLinesAroundBlockBody: + Enabled: true + EnforcedStyle: no_empty_lines + +Layout/EmptyLinesAroundClassBody: + Enabled: true + EnforcedStyle: no_empty_lines + +Layout/EmptyLinesAroundExceptionHandlingKeywords: + Enabled: true + +Layout/EmptyLinesAroundMethodBody: + Enabled: true + +Layout/EmptyLinesAroundModuleBody: + Enabled: true + EnforcedStyle: no_empty_lines + +Layout/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: variable + Severity: warning + +Layout/EndOfLine: Enabled: true -Gemspec/RequireMFA: # new in 1.23 + EnforcedStyle: native + +Layout/ExtraSpacing: Enabled: true -Layout/LineContinuationLeadingSpace: # new in 1.31 + AllowForAlignment: false + AllowBeforeTrailingComments: true + ForceEqualSignAlignment: false + +Layout/FirstArgumentIndentation: Enabled: true -Layout/LineContinuationSpacing: # new in 1.31 + EnforcedStyle: consistent + IndentationWidth: ~ + +Layout/FirstArrayElementIndentation: Enabled: true -Layout/LineEndStringConcatenationIndentation: # new in 1.18 + EnforcedStyle: consistent + IndentationWidth: ~ + +Layout/FirstArrayElementLineBreak: + Enabled: false + +Layout/FirstHashElementIndentation: Enabled: true -Layout/SpaceBeforeBrackets: # new in 1.7 + EnforcedStyle: consistent + IndentationWidth: ~ + +Layout/FirstHashElementLineBreak: + Enabled: false + +Layout/FirstMethodArgumentLineBreak: + Enabled: false + +Layout/FirstMethodParameterLineBreak: + Enabled: false + +Layout/FirstParameterIndentation: + Enabled: false + +Layout/HashAlignment: Enabled: true -Lint/AmbiguousAssignment: # new in 1.7 + EnforcedHashRocketStyle: key + EnforcedColonStyle: key + EnforcedLastArgumentHashStyle: always_inspect + +Layout/HeredocArgumentClosingParenthesis: + Enabled: false + +Layout/HeredocIndentation: Enabled: true -Lint/AmbiguousOperatorPrecedence: # new in 1.21 + +Layout/IndentationConsistency: Enabled: true -Lint/AmbiguousRange: # new in 1.19 + EnforcedStyle: normal + +Layout/IndentationStyle: Enabled: true -Lint/ConstantOverwrittenInRescue: # new in 1.31 + IndentationWidth: ~ + +Layout/IndentationWidth: Enabled: true -Lint/DeprecatedConstants: # new in 1.8 + Width: 2 + AllowedPatterns: [] + +Layout/InitialIndentation: Enabled: true -Lint/DuplicateBranch: # new in 1.3 + +Layout/LeadingCommentSpace: Enabled: true -Lint/DuplicateMagicComment: # new in 1.37 + +Layout/LeadingEmptyLines: Enabled: true -Lint/DuplicateMatchPattern: # new in 1.50 + +Layout/LineContinuationLeadingSpace: + Enabled: false + +Layout/LineContinuationSpacing: Enabled: true -Lint/DuplicateRegexpCharacterClassElement: # new in 1.1 + +Layout/LineEndStringConcatenationIndentation: + Enabled: false + +Layout/LineLength: + Enabled: false + +Layout/MultilineArrayBraceLayout: Enabled: true -Lint/EmptyBlock: # new in 1.1 - Enabled: False -Lint/EmptyClass: # new in 1.3 + EnforcedStyle: symmetrical + +Layout/MultilineArrayLineBreaks: + Enabled: false + +Layout/MultilineAssignmentLayout: + Enabled: false + +Layout/MultilineBlockLayout: Enabled: true -Lint/EmptyInPattern: # new in 1.16 + +Layout/MultilineHashBraceLayout: Enabled: true -Lint/IncompatibleIoSelectWithFiberScheduler: # new in 1.21 + EnforcedStyle: symmetrical + +Layout/MultilineHashKeyLineBreaks: + Enabled: false + +Layout/MultilineMethodArgumentLineBreaks: + Enabled: false + +Layout/MultilineMethodCallBraceLayout: Enabled: true -Lint/LambdaWithoutLiteralBlock: # new in 1.8 + EnforcedStyle: symmetrical + +Layout/MultilineMethodCallIndentation: Enabled: true -Lint/MixedCaseRange: # new in 1.53 + EnforcedStyle: indented + IndentationWidth: ~ + +Layout/MultilineMethodDefinitionBraceLayout: Enabled: true -Lint/NoReturnInBeginEndBlocks: # new in 1.2 + EnforcedStyle: symmetrical + +Layout/MultilineMethodParameterLineBreaks: + Enabled: false + +Layout/MultilineOperationIndentation: Enabled: true -Lint/NonAtomicFileOperation: # new in 1.31 + EnforcedStyle: indented + IndentationWidth: ~ + +Layout/ParameterAlignment: Enabled: true -Lint/NumberedParameterAssignment: # new in 1.9 + EnforcedStyle: with_fixed_indentation + IndentationWidth: ~ + +Layout/RedundantLineBreak: + Enabled: false + +Layout/RescueEnsureAlignment: Enabled: true -Lint/OrAssignmentToConstant: # new in 1.9 + +Layout/SingleLineBlockChain: + Enabled: false + +Layout/SpaceAfterColon: Enabled: true -Lint/RedundantDirGlobSort: # new in 1.8 + +Layout/SpaceAfterComma: Enabled: true -Lint/RedundantRegexpQuantifiers: # new in 1.53 + +Layout/SpaceAfterMethodName: Enabled: true -Lint/RefinementImportMethods: # new in 1.27 + +Layout/SpaceAfterNot: Enabled: true -Lint/RequireRangeParentheses: # new in 1.32 + +Layout/SpaceAfterSemicolon: Enabled: true -Lint/RequireRelativeSelfPath: # new in 1.22 + +Layout/SpaceAroundBlockParameters: Enabled: true -Lint/SymbolConversion: # new in 1.9 + EnforcedStyleInsidePipes: no_space + +Layout/SpaceAroundEqualsInParameterDefault: Enabled: true -Lint/ToEnumArguments: # new in 1.1 + EnforcedStyle: space + +Layout/SpaceAroundKeyword: Enabled: true -Lint/TripleQuotes: # new in 1.9 + +Layout/SpaceAroundMethodCallOperator: Enabled: true -Lint/UnexpectedBlockArity: # new in 1.5 + +Layout/SpaceAroundOperators: Enabled: true -Lint/UnmodifiedReduceAccumulator: # new in 1.1 + AllowForAlignment: true + +Layout/SpaceBeforeBlockBraces: Enabled: true -Lint/UselessRescue: # new in 1.43 + EnforcedStyle: space + EnforcedStyleForEmptyBraces: space + +Layout/SpaceBeforeBrackets: + Enabled: false + +Layout/SpaceBeforeComma: Enabled: true -Lint/UselessRuby2Keywords: # new in 1.23 + +Layout/SpaceBeforeComment: Enabled: true -Metrics/CollectionLiteralLength: # new in 1.47 + +Layout/SpaceBeforeFirstArg: Enabled: true -Naming/BlockForwarding: # new in 1.24 + AllowForAlignment: true + +Layout/SpaceBeforeSemicolon: Enabled: true -Security/CompoundHash: # new in 1.28 + +Layout/SpaceInLambdaLiteral: Enabled: true -Security/IoMethods: # new in 1.22 + EnforcedStyle: require_no_space + +Layout/SpaceInsideArrayLiteralBrackets: Enabled: true -Style/ArgumentsForwarding: # new in 1.1 + EnforcedStyle: no_space + EnforcedStyleForEmptyBrackets: no_space + +Layout/SpaceInsideArrayPercentLiteral: Enabled: true -Style/ArrayIntersect: # new in 1.40 + +Layout/SpaceInsideBlockBraces: Enabled: true -Style/CollectionCompact: # new in 1.2 + EnforcedStyle: space + EnforcedStyleForEmptyBraces: no_space + SpaceBeforeBlockParameters: true + +Layout/SpaceInsideHashLiteralBraces: Enabled: true -Style/ComparableClamp: # new in 1.44 + EnforcedStyle: no_space + EnforcedStyleForEmptyBraces: no_space + +Layout/SpaceInsideParens: Enabled: true -Style/ConcatArrayLiterals: # new in 1.41 + EnforcedStyle: no_space + +Layout/SpaceInsidePercentLiteralDelimiters: Enabled: true -Style/DataInheritance: # new in 1.49 + +Layout/SpaceInsideRangeLiteral: Enabled: true -Style/DirEmpty: # new in 1.48 + +Layout/SpaceInsideReferenceBrackets: Enabled: true -Style/DocumentDynamicEvalDefinition: # new in 1.1 + EnforcedStyle: no_space + EnforcedStyleForEmptyBrackets: no_space + +Layout/SpaceInsideStringInterpolation: Enabled: true -Style/EmptyHeredoc: # new in 1.32 + EnforcedStyle: no_space + +Layout/TrailingEmptyLines: Enabled: true -Style/EndlessMethod: # new in 1.8 + EnforcedStyle: final_newline + +Layout/TrailingWhitespace: Enabled: true -Style/EnvHome: # new in 1.29 + AllowInHeredoc: true + +Lint/AmbiguousAssignment: Enabled: true -Style/ExactRegexpMatch: # new in 1.51 + +Lint/AmbiguousBlockAssociation: + Enabled: false + +Lint/AmbiguousOperator: Enabled: true -Style/FetchEnvVar: # new in 1.28 + +Lint/AmbiguousOperatorPrecedence: + Enabled: false + +Lint/AmbiguousRange: + Enabled: false + +Lint/AmbiguousRegexpLiteral: Enabled: true -Style/FileEmpty: # new in 1.48 + +Lint/AssignmentInCondition: Enabled: true -Style/FileRead: # new in 1.24 + AllowSafeAssignment: true + # Intentionally disable autocorrect to force us to intentionally decide + # whether assignment is intended as opposed to comparison + AutoCorrect: false + +Lint/BigDecimalNew: Enabled: true -Style/FileWrite: # new in 1.24 + +Lint/BinaryOperatorWithIdenticalOperands: Enabled: true -Style/HashConversion: # new in 1.10 + +Lint/BooleanSymbol: Enabled: true -Style/HashExcept: # new in 1.7 + +Lint/CircularArgumentReference: Enabled: true -Style/IfWithBooleanLiteralBranches: # new in 1.9 + +Lint/ConstantDefinitionInBlock: Enabled: true -Style/InPatternThen: # new in 1.16 + +Lint/ConstantOverwrittenInRescue: Enabled: true -Style/MagicCommentFormat: # new in 1.35 + +Lint/ConstantResolution: + Enabled: false + +Lint/Debugger: Enabled: true -Style/MapCompactWithConditionalBlock: # new in 1.30 + +Lint/DeprecatedClassMethods: Enabled: true -Style/MapToHash: # new in 1.24 + +Lint/DeprecatedConstants: Enabled: true -Style/MapToSet: # new in 1.42 + +Lint/DeprecatedOpenSSLConstant: Enabled: true -Style/MinMaxComparison: # new in 1.42 + +Lint/DisjunctiveAssignmentInConstructor: + Enabled: false + +Lint/DuplicateBranch: + Enabled: false + +Lint/DuplicateCaseCondition: Enabled: true -Style/MultilineInPatternThen: # new in 1.16 + +Lint/DuplicateElsifCondition: Enabled: true -Style/NegatedIfElseCondition: # new in 1.2 + +Lint/DuplicateHashKey: Enabled: true -Style/NestedFileDirname: # new in 1.26 + +Lint/DuplicateMagicComment: Enabled: true -Style/NilLambda: # new in 1.3 + +Lint/DuplicateMatchPattern: + Enabled: false + +Lint/DuplicateMethods: Enabled: true -Style/NumberedParameters: # new in 1.22 + +Lint/DuplicateRegexpCharacterClassElement: Enabled: true -Style/NumberedParametersLimit: # new in 1.22 + +Lint/DuplicateRequire: Enabled: true -Style/ObjectThen: # new in 1.28 + +Lint/DuplicateRescueException: Enabled: true -Style/OpenStructUse: # new in 1.23 + +Lint/EachWithObjectArgument: Enabled: true -Style/OperatorMethodCall: # new in 1.37 + +Lint/ElseLayout: Enabled: true -Style/QuotedSymbols: # new in 1.16 + +Lint/EmptyBlock: + Enabled: false + +Lint/EmptyClass: + Enabled: false + +Lint/EmptyConditionalBody: + Enabled: false + +Lint/EmptyEnsure: Enabled: true -Style/RedundantArgument: # new in 1.4 + +Lint/EmptyExpression: Enabled: true -Style/RedundantArrayConstructor: # new in 1.52 + +Lint/EmptyFile: + Enabled: false + +Lint/EmptyInPattern: + Enabled: false + +Lint/EmptyInterpolation: Enabled: true -Style/RedundantConstantBase: # new in 1.40 + +Lint/EmptyWhen: Enabled: true -Style/RedundantCurrentDirectoryInPath: # new in 1.53 + AllowComments: true + +Lint/EnsureReturn: Enabled: true -Style/RedundantDoubleSplatHashBraces: # new in 1.41 + +Lint/ErbNewArguments: Enabled: true -Style/RedundantEach: # new in 1.38 + +Lint/FlipFlop: Enabled: true -Style/RedundantFilterChain: # new in 1.52 + +Lint/FloatComparison: Enabled: true -Style/RedundantHeredocDelimiterQuotes: # new in 1.45 + +Lint/FloatOutOfRange: Enabled: true -Style/RedundantInitialize: # new in 1.27 + +Lint/FormatParameterMismatch: Enabled: true -Style/RedundantLineContinuation: # new in 1.49 + +Lint/HashCompareByIdentity: + Enabled: false + +Lint/HeredocMethodCallPosition: + Enabled: false + +Lint/IdentityComparison: Enabled: true -Style/RedundantRegexpArgument: # new in 1.53 + +Lint/ImplicitStringConcatenation: Enabled: true -Style/RedundantRegexpConstructor: # new in 1.52 + +Lint/IncompatibleIoSelectWithFiberScheduler: + Enabled: false + +Lint/IneffectiveAccessModifier: Enabled: true -Style/RedundantSelfAssignmentBranch: # new in 1.19 + +Lint/InheritException: Enabled: true -Style/RedundantStringEscape: # new in 1.37 + EnforcedStyle: runtime_error + +Lint/InterpolationCheck: Enabled: true -Style/ReturnNilInPredicateMethodDefinition: # new in 1.53 + +Lint/ItWithoutArgumentsInBlock: Enabled: true -Style/SelectByRegexp: # new in 1.22 + +Lint/LambdaWithoutLiteralBlock: + Enabled: false + +Lint/LiteralAsCondition: Enabled: true -Style/SingleLineDoEndBlock: # new in 1.57 + +Lint/LiteralAssignmentInCondition: Enabled: true -Style/StringChars: # new in 1.12 + +Lint/LiteralInInterpolation: Enabled: true -Style/SwapValues: # new in 1.1 + +Lint/Loop: Enabled: true -Style/YAMLFileRead: # new in 1.53 + +Lint/MissingCopEnableDirective: Enabled: true + MaximumRangeSize: .inf + +Lint/MissingSuper: + Enabled: false + +Lint/MixedCaseRange: + Enabled: true + +Lint/MixedRegexpCaptureTypes: + Enabled: true + +Lint/MultipleComparison: + Enabled: true + +Lint/NestedMethodDefinition: + Enabled: true + +Lint/NestedPercentLiteral: + Enabled: true + +Lint/NextWithoutAccumulator: + Enabled: true + +Lint/NoReturnInBeginEndBlocks: + Enabled: false + +Lint/NonAtomicFileOperation: + Enabled: false + +Lint/NonDeterministicRequireOrder: + Enabled: true + +Lint/NonLocalExitFromIterator: + Enabled: true + +Lint/NumberConversion: + Enabled: false + +Lint/NumberedParameterAssignment: + Enabled: true + +Lint/OrAssignmentToConstant: + Enabled: true + +Lint/OrderedMagicComments: + Enabled: true + +Lint/OutOfRangeRegexpRef: + Enabled: true + +Lint/ParenthesesAsGroupedExpression: + Enabled: true + +Lint/PercentStringArray: + Enabled: false + +Lint/PercentSymbolArray: + Enabled: true + +Lint/RaiseException: + Enabled: true + +Lint/RandOne: + Enabled: true + +Lint/RedundantCopDisableDirective: + Enabled: false + +Lint/RedundantCopEnableDirective: + Enabled: false + +Lint/RedundantDirGlobSort: + Enabled: false + +Lint/RedundantRegexpQuantifiers: + Enabled: true + +Lint/RedundantRequireStatement: + Enabled: true + +Lint/RedundantSafeNavigation: + Enabled: false + +Lint/RedundantSplatExpansion: + Enabled: true + +Lint/RedundantStringCoercion: + Enabled: true + +Lint/RedundantWithIndex: + Enabled: true + +Lint/RedundantWithObject: + Enabled: true + +Lint/RefinementImportMethods: + Enabled: true + +Lint/RegexpAsCondition: + Enabled: true + +Lint/RequireParentheses: + Enabled: true + +Lint/RequireRangeParentheses: + Enabled: true + +Lint/RequireRelativeSelfPath: + Enabled: true + +Lint/RescueException: + Enabled: true + +Lint/RescueType: + Enabled: true + +Lint/ReturnInVoidContext: + Enabled: true + +Lint/SafeNavigationChain: + Enabled: true + AllowedMethods: + - present? + - blank? + - presence + - try + - try! + +Lint/SafeNavigationConsistency: + Enabled: true + AllowedMethods: + - present? + - blank? + - presence + - try + - try! + +Lint/SafeNavigationWithEmpty: + Enabled: true + +Lint/ScriptPermission: + Enabled: false + +Lint/SelfAssignment: + Enabled: true + +Lint/SendWithMixinArgument: + Enabled: false + +Lint/ShadowedArgument: + Enabled: true + IgnoreImplicitReferences: false + +Lint/ShadowedException: + Enabled: true + +Lint/ShadowingOuterLocalVariable: + Enabled: false + +Lint/StructNewOverride: + Enabled: false + +Lint/SuppressedException: + Enabled: false + +Lint/SymbolConversion: + Enabled: true + +Lint/ToEnumArguments: + Enabled: false + +Lint/ToJSON: + Enabled: false + +Lint/TopLevelReturnWithArgument: + Enabled: true + +Lint/TrailingCommaInAttributeDeclaration: + Enabled: true + +Lint/TripleQuotes: + Enabled: true + +Lint/UnderscorePrefixedVariableName: + Enabled: true + +Lint/UnexpectedBlockArity: + Enabled: false + +Lint/UnifiedInteger: + Enabled: true + +Lint/UnmodifiedReduceAccumulator: + Enabled: false + +Lint/UnreachableCode: + Enabled: true + +Lint/UnreachableLoop: + Enabled: false + +Lint/UnusedBlockArgument: + Enabled: false + +Lint/UnusedMethodArgument: + Enabled: false + +Lint/UriEscapeUnescape: + Enabled: true + +Lint/UriRegexp: + Enabled: true + +Lint/UselessAccessModifier: + Enabled: false + +Lint/UselessAssignment: + Enabled: true + +Lint/UselessElseWithoutRescue: + Enabled: false + +Lint/UselessMethodDefinition: + Enabled: false + +Lint/UselessRescue: + Enabled: true + +Lint/UselessRuby2Keywords: + Enabled: true + +Lint/UselessSetterCall: + Enabled: true + +Lint/UselessTimes: + Enabled: true + +Lint/Void: + Enabled: true + CheckForMethodsWithNoSideEffects: false + +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/BlockNesting: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CollectionLiteralLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/ParameterLists: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Migration/DepartmentName: + Enabled: true + +Naming/AccessorMethodName: + Enabled: false + +Naming/AsciiIdentifiers: + Enabled: false + +Naming/BinaryOperatorParameterName: + Enabled: true + +Naming/BlockForwarding: + Enabled: false + +Naming/BlockParameterName: + Enabled: true + MinNameLength: 1 + AllowNamesEndingInNumbers: true + AllowedNames: [] + ForbiddenNames: [] + +Naming/ClassAndModuleCamelCase: + Enabled: true + +Naming/ConstantName: + Enabled: true + +Naming/FileName: + Enabled: false + +Naming/HeredocDelimiterCase: + Enabled: true + EnforcedStyle: uppercase + +Naming/HeredocDelimiterNaming: + Enabled: false + +Naming/InclusiveLanguage: + Enabled: false + +Naming/MemoizedInstanceVariableName: + Enabled: false + +Naming/MethodName: + Enabled: false + +Naming/MethodParameterName: + Enabled: false + +Naming/PredicateName: + Enabled: false + +Naming/RescuedExceptionsVariableName: + Enabled: false + +Naming/VariableName: + Enabled: true + EnforcedStyle: snake_case + +Naming/VariableNumber: + Enabled: false + +Security/CompoundHash: + Enabled: true + +Security/Eval: + Enabled: true + +Security/IoMethods: + Enabled: false + +Security/JSONLoad: + Enabled: true + +Security/MarshalLoad: + Enabled: false + +Security/Open: + Enabled: true + +Security/YAMLLoad: + Enabled: true + +Style/AccessModifierDeclarations: + Enabled: false + +Style/AccessorGrouping: + Enabled: false + +Style/Alias: + Enabled: true + EnforcedStyle: prefer_alias_method + +Style/AndOr: + Enabled: true + +Style/ArgumentsForwarding: + Enabled: true + +Style/ArrayCoercion: + Enabled: false + +Style/ArrayFirstLast: + Enabled: false + +Style/ArrayIntersect: + Enabled: false + +Style/ArrayJoin: + Enabled: true + +Style/AsciiComments: + Enabled: false + +Style/Attr: + Enabled: true + +Style/AutoResourceCleanup: + Enabled: false + +Style/BarePercentLiterals: + Enabled: true + EnforcedStyle: bare_percent + +Style/BeginBlock: + Enabled: true + +Style/BisectedAttrAccessor: + Enabled: false + +Style/BlockComments: + Enabled: true + +Style/BlockDelimiters: + Enabled: false + +Style/CaseEquality: + Enabled: false + +Style/CaseLikeIf: + Enabled: false + +Style/CharacterLiteral: + Enabled: true + +Style/ClassAndModuleChildren: + Enabled: false + +Style/ClassCheck: + Enabled: true + EnforcedStyle: is_a? + +Style/ClassEqualityComparison: + Enabled: true + +Style/ClassMethods: + Enabled: true + +Style/ClassMethodsDefinitions: + Enabled: false + +Style/ClassVars: + Enabled: false + +Style/CollectionCompact: + Enabled: false + +Style/CollectionMethods: + Enabled: false + +Style/ColonMethodCall: + Enabled: true + +Style/ColonMethodDefinition: + Enabled: true + +Style/CombinableLoops: + Enabled: false + +Style/CommandLiteral: + Enabled: true + EnforcedStyle: mixed + AllowInnerBackticks: false + +Style/CommentAnnotation: + Enabled: false + +Style/CommentedKeyword: + Enabled: false + +Style/ComparableClamp: + Enabled: true + +Style/ConcatArrayLiterals: + Enabled: false + +Style/ConditionalAssignment: + Enabled: true + EnforcedStyle: assign_to_condition + SingleLineConditionsOnly: true + IncludeTernaryExpressions: true + +Style/ConstantVisibility: + Enabled: false + +Style/Copyright: + Enabled: false + +Style/DataInheritance: + Enabled: false + +Style/DateTime: + Enabled: false + +Style/DefWithParentheses: + Enabled: true + +Style/Dir: + Enabled: true + +Style/DirEmpty: + Enabled: true + +Style/DisableCopsWithinSourceCodeDirective: + Enabled: false + +Style/DocumentDynamicEvalDefinition: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/DocumentationMethod: + Enabled: false + +Style/DoubleCopDisableDirective: + Enabled: false + +Style/DoubleNegation: + Enabled: false + +Style/EachForSimpleLoop: + Enabled: true + +Style/EachWithObject: + Enabled: true + +Style/EmptyBlockParameter: + Enabled: true + +Style/EmptyCaseCondition: + Enabled: true + +Style/EmptyElse: + Enabled: true + AllowComments: true + EnforcedStyle: both + +Style/EmptyHeredoc: + Enabled: false + +Style/EmptyLambdaParameter: + Enabled: true + +Style/EmptyLiteral: + Enabled: true + +Style/EmptyMethod: + Enabled: true + EnforcedStyle: expanded + +Style/Encoding: + Enabled: true + +Style/EndBlock: + Enabled: true + +Style/EndlessMethod: + Enabled: false + +Style/EnvHome: + Enabled: false + +Style/EvalWithLocation: + Enabled: true + +Style/EvenOdd: + Enabled: false + +Style/ExactRegexpMatch: + Enabled: true +Style/ExpandPathArguments: + Enabled: false + +Style/ExplicitBlockArgument: + Enabled: false + +Style/ExponentialNotation: + Enabled: false + +Style/FetchEnvVar: + Enabled: false + +Style/FileEmpty: + Enabled: false + +Style/FileRead: + Enabled: true + +Style/FileWrite: + Enabled: true + +Style/FloatDivision: + Enabled: false + +Style/For: + Enabled: true + EnforcedStyle: each + +Style/FormatString: + Enabled: false + +Style/FormatStringToken: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/GlobalStdStream: + Enabled: true + +Style/GlobalVars: + Enabled: true + AllowedVariables: [] + +Style/GuardClause: + Enabled: false + +Style/HashAsLastArrayItem: + Enabled: false + +Style/HashConversion: + Enabled: true + +Style/HashEachMethods: + Enabled: false + +Style/HashExcept: + Enabled: true + +Style/HashLikeCase: + Enabled: false + +Style/HashSyntax: + Enabled: true + EnforcedStyle: ruby19_no_mixed_keys + EnforcedShorthandSyntax: either + +Style/HashTransformKeys: + Enabled: false + +Style/HashTransformValues: + Enabled: false + +Style/IdenticalConditionalBranches: + Enabled: true + +Style/IfInsideElse: + Enabled: true + +Style/IfUnlessModifier: + Enabled: false + +Style/IfUnlessModifierOfIfUnless: + Enabled: true + +Style/IfWithBooleanLiteralBranches: + Enabled: true + +Style/IfWithSemicolon: + Enabled: true + +Style/ImplicitRuntimeError: + Enabled: false + +Style/InPatternThen: + Enabled: false + +Style/InfiniteLoop: + Enabled: true + +Style/InlineComment: + Enabled: false + +Style/InverseMethods: + Enabled: false + +Style/InvertibleUnlessCondition: + Enabled: false + +Style/IpAddresses: + Enabled: false + +Style/KeywordParametersOrder: + Enabled: true + +Style/Lambda: + Enabled: false + +Style/LambdaCall: + Enabled: true + EnforcedStyle: call + +Style/LineEndConcatenation: + Enabled: true + +Style/MagicCommentFormat: + Enabled: false + +Style/MapCompactWithConditionalBlock: + Enabled: true + +Style/MapIntoArray: + Enabled: false + +Style/MapToHash: + Enabled: false + +Style/MapToSet: + Enabled: false + +Style/MethodCallWithArgsParentheses: + Enabled: false + +Style/MethodCallWithoutArgsParentheses: + Enabled: true + AllowedMethods: [] + +Style/MethodCalledOnDoEndBlock: + Enabled: false + +Style/MethodDefParentheses: + Enabled: false + +Style/MinMax: + Enabled: false + +Style/MinMaxComparison: + Enabled: false + +Style/MissingElse: + Enabled: false + +Style/MissingRespondToMissing: + Enabled: true + +Style/MixinGrouping: + Enabled: true + EnforcedStyle: separated + +Style/MixinUsage: + Enabled: true + +Style/ModuleFunction: + Enabled: false + +Style/MultilineBlockChain: + Enabled: false + +Style/MultilineIfModifier: + Enabled: true + +Style/MultilineIfThen: + Enabled: true + +Style/MultilineInPatternThen: + Enabled: false + +Style/MultilineMemoization: + Enabled: true + EnforcedStyle: keyword + +Style/MultilineMethodSignature: + Enabled: false + +Style/MultilineTernaryOperator: + Enabled: false + +Style/MultilineWhenThen: + Enabled: true + +Style/MultipleComparison: + Enabled: false + +Style/MutableConstant: + Enabled: false + +Style/NegatedIf: + Enabled: false + +Style/NegatedIfElseCondition: + Enabled: false + +Style/NegatedUnless: + Enabled: false + +Style/NegatedWhile: + Enabled: true + +Style/NestedFileDirname: + Enabled: true + +Style/NestedModifier: + Enabled: true + +Style/NestedParenthesizedCalls: + Enabled: true + AllowedMethods: + - be + - be_a + - be_an + - be_between + - be_falsey + - be_kind_of + - be_instance_of + - be_truthy + - be_within + - eq + - eql + - end_with + - include + - match + - raise_error + - respond_to + - start_with + +Style/NestedTernaryOperator: + Enabled: true + +Style/Next: + Enabled: false + +Style/NilComparison: + Enabled: true + EnforcedStyle: predicate + +Style/NilLambda: + Enabled: true + +Style/NonNilCheck: + Enabled: true + IncludeSemanticChanges: false + +Style/Not: + Enabled: true + +Style/NumberedParameters: + Enabled: false + +Style/NumberedParametersLimit: + Enabled: false + +Style/NumericLiteralPrefix: + Enabled: true + EnforcedOctalStyle: zero_with_o + +Style/NumericLiterals: + Enabled: false + +Style/NumericPredicate: + Enabled: false + +Style/ObjectThen: + Enabled: false + +Style/OneLineConditional: + Enabled: true + +Style/OpenStructUse: + Enabled: false + +Style/OperatorMethodCall: + Enabled: false + +Style/OptionHash: + Enabled: false + +Style/OptionalArguments: + Enabled: true + +Style/OptionalBooleanParameter: + Enabled: false + +Style/OrAssignment: + Enabled: true + +Style/ParallelAssignment: + Enabled: false + +Style/ParenthesesAroundCondition: + Enabled: true + AllowSafeAssignment: true + AllowInMultilineConditions: false + +Style/PercentLiteralDelimiters: + Enabled: true + PreferredDelimiters: + default: () + '%i': '[]' + '%I': '[]' + '%r': '{}' + '%w': '[]' + '%W': '[]' + +Style/PercentQLiterals: + Enabled: false + +Style/PerlBackrefs: + Enabled: false + +Style/PreferredHashMethods: + Enabled: false + +Style/Proc: + Enabled: true + +Style/QuotedSymbols: + Enabled: true + EnforcedStyle: same_as_string_literals + +Style/RaiseArgs: + Enabled: false + +Style/RandomWithOffset: + Enabled: true + +Style/RedundantArgument: + Enabled: false + +Style/RedundantArrayConstructor: + Enabled: true + +Style/RedundantAssignment: + Enabled: true + +Style/RedundantBegin: + Enabled: true + +Style/RedundantCapitalW: + Enabled: false + +Style/RedundantCondition: + Enabled: true + +Style/RedundantConditional: + Enabled: true + +Style/RedundantConstantBase: + Enabled: false + +Style/RedundantCurrentDirectoryInPath: + Enabled: true + +Style/RedundantDoubleSplatHashBraces: + Enabled: true + +Style/RedundantEach: + Enabled: false + +Style/RedundantException: + Enabled: true + +Style/RedundantFetchBlock: + Enabled: false + +Style/RedundantFileExtensionInRequire: + Enabled: true + +Style/RedundantFilterChain: + Enabled: false + +Style/RedundantFreeze: + Enabled: true + +Style/RedundantHeredocDelimiterQuotes: + Enabled: true + +Style/RedundantInitialize: + Enabled: false + +Style/RedundantInterpolation: + Enabled: true + +Style/RedundantLineContinuation: + Enabled: true + +Style/RedundantParentheses: + Enabled: true + +Style/RedundantPercentQ: + Enabled: true + +Style/RedundantRegexpArgument: + Enabled: true + +Style/RedundantRegexpCharacterClass: + Enabled: true + +Style/RedundantRegexpConstructor: + Enabled: true + +Style/RedundantRegexpEscape: + Enabled: true + +Style/RedundantReturn: + Enabled: true + AllowMultipleReturnValues: false + +Style/RedundantSelf: + Enabled: true + +Style/RedundantSelfAssignment: + Enabled: false + +Style/RedundantSelfAssignmentBranch: + Enabled: false + +Style/RedundantSort: + Enabled: true + +Style/RedundantSortBy: + Enabled: true + +Style/RedundantStringEscape: + Enabled: true + +Style/RegexpLiteral: + Enabled: false + +Style/RequireOrder: + Enabled: false + +Style/RescueModifier: + Enabled: true + +Style/RescueStandardError: + Enabled: true + EnforcedStyle: implicit + +Style/ReturnNil: + Enabled: false + +Style/ReturnNilInPredicateMethodDefinition: + Enabled: false + +Style/SafeNavigation: + Enabled: true + ConvertCodeThatCanStartToReturnNil: false + AllowedMethods: + - present? + - blank? + - presence + - try + - try! + +Style/Sample: + Enabled: true + +Style/SelectByRegexp: + Enabled: false + +Style/SelfAssignment: + Enabled: true + +Style/Semicolon: + Enabled: true + AllowAsExpressionSeparator: false + +Style/Send: + Enabled: false + +Style/SendWithLiteralMethodName: + Enabled: false + +Style/SignalException: + Enabled: false + +Style/SingleArgumentDig: + Enabled: false + +Style/SingleLineBlockParams: + Enabled: false + +Style/SingleLineDoEndBlock: + Enabled: false + +Style/SingleLineMethods: + Enabled: true + AllowIfMethodIsEmpty: false + +Style/SlicingWithRange: + Enabled: true + +Style/SoleNestedConditional: + Enabled: false + +Style/SpecialGlobalVars: + Enabled: false + +Style/StabbyLambdaParentheses: + Enabled: true + EnforcedStyle: require_parentheses + +Style/StaticClass: + Enabled: false + +Style/StderrPuts: + Enabled: true + +Style/StringChars: + Enabled: true + +Style/StringConcatenation: + Enabled: false + +Style/StringHashKeys: + Enabled: false + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + ConsistentQuotesInMultiline: false + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringMethods: + Enabled: false + +Style/Strip: + Enabled: true + +Style/StructInheritance: + Enabled: false + +Style/SuperArguments: + Enabled: true + +Style/SuperWithArgsParentheses: + Enabled: true + +Style/SwapValues: + Enabled: false + +Style/SymbolArray: + Enabled: false + +Style/SymbolLiteral: + Enabled: true + +Style/SymbolProc: + Enabled: false + +Style/TernaryParentheses: + Enabled: true + EnforcedStyle: require_parentheses_when_complex + AllowSafeAssignment: true + +Style/TopLevelMethodDefinition: + Enabled: false + +Style/TrailingBodyOnClass: + Enabled: true + +Style/TrailingBodyOnMethodDefinition: + Enabled: true + +Style/TrailingBodyOnModule: + Enabled: true + +Style/TrailingCommaInArguments: + Enabled: true + EnforcedStyleForMultiline: no_comma + +Style/TrailingCommaInArrayLiteral: + Enabled: true + EnforcedStyleForMultiline: no_comma + +Style/TrailingCommaInBlockArgs: + Enabled: true + +Style/TrailingCommaInHashLiteral: + Enabled: true + EnforcedStyleForMultiline: no_comma + +Style/TrailingMethodEndStatement: + Enabled: true + +Style/TrailingUnderscoreVariable: + Enabled: false + +Style/TrivialAccessors: + Enabled: true + ExactNameMatch: true + AllowPredicates: true + AllowDSLWriters: false + IgnoreClassMethods: true + AllowedMethods: + - to_ary + - to_a + - to_c + - to_enum + - to_h + - to_hash + - to_i + - to_int + - to_io + - to_open + - to_path + - to_proc + - to_r + - to_regexp + - to_str + - to_s + - to_sym + +Style/UnlessElse: + Enabled: true + +Style/UnlessLogicalOperators: + Enabled: true + EnforcedStyle: forbid_mixed_logical_operators + +Style/UnpackFirst: + Enabled: true + +Style/VariableInterpolation: + Enabled: true + +Style/WhenThen: + Enabled: true + +Style/WhileUntilDo: + Enabled: true + +Style/WhileUntilModifier: + Enabled: false + +Style/WordArray: + Enabled: false + +Style/YAMLFileRead: + Enabled: true + +Style/YodaCondition: + Enabled: true + EnforcedStyle: forbid_for_all_comparison_operators + +Style/YodaExpression: + Enabled: false -Rails/I18nLocaleTexts: +Style/ZeroLengthPredicate: Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000000..6cb9d3dd0d6 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.3 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 00000000000..a81a215476c --- /dev/null +++ b/Caddyfile @@ -0,0 +1,19 @@ +{ + # Replace with your email for Let's Encrypt notifications + email user@example.com + acme_ca https://acme-v02.api.letsencrypt.org/directory # Use Let's Encrypt production API +} + +# Replace "localhost" with your domain (or subdomain) and SSL +# certificates will automatically retrieved and managed. +# +# Example: pwp.example.com +# +localhost { + reverse_proxy pwpush:5100 + + header { + Strict-Transport-Security max-age=31536000 + Set-Cookie (.*) "$1; Secure" + } +} diff --git a/Configuration.md b/Configuration.md index 29410674461..92ebf6cdd3a 100644 --- a/Configuration.md +++ b/Configuration.md @@ -1,577 +1,5 @@ +Configuration documentation has now been moved to the [documentation portal](https://docs.pwpush.com/docs/config-strategies/). -# Overview +If you have any problems or feedback with the new documentation portal, let me know and file an issue. -Configure everything from defaults, features, branding, languages and more. - -# How to Configure the Application - -Password Pusher uses a centralized configuration that is stored in [config/settings.yml](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml). This file contains all of the settings that is configurable for the application. - -There are two ways to modify the settings in this file: - -1. Use environment variable that override this file -2. Modify the file itself - -For a few modifications, environment variables are the easy route. For more extensive configuration, it's suggested to maintain your own custom `settings.yml` file across updates. - -Read on for details on both methods. - -## Configuring via Environment variables - -The settings in the `config/settings.yml` file can be overridden by environment variables. A listing and description of these environment variables is available in this documentation below and also in the [settings.yml](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml) file itself. - -### Shell Example - -```sh -# Change the default language for the application to French -export PWP__DEFAULT_LOCALE='fr' -``` -### Docker Example - -```sh -# Change the default language for the application to French -docker run -d --env PWP__DEFAULT_LOCALE=fr -p "5100:5100" pglombardo/pwpush:release -``` - -_Tip: If you have to set a large number of environment variables for Docker, consider using a Docker env-file. There is an [example docker-env-file](https://github.com/pglombardo/PasswordPusher/blob/master/containers/docker/pwpush-docker-env-file) with instructions available._ - -## Configuring via a Custom `settings.yml` File - -If you prefer, you can take the [default settings.yml file](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml), modify it and apply it to the Password Pusher Docker container. - -Inside the Password Pusher Docker container: -* application code exists in the path `/opt/PasswordPusher/` -* the `settings.yml` file is located at `/opt/PasswordPusher/config/settings.yml` - -To replace this file with your own custom version, you can launch the Docker container with a bind mount option: - -```sh - docker run -d \ - --mount type=bind,source=/path/settings.yml,target=/opt/PasswordPusher/config/settings.yml \ - -p "5100:5100" pglombardo/pwpush:release -``` - -# Application Encryption - -Password Pusher encrypts sensitive data in the database. This requires a randomly generated encryption key for each application instance. - -To set a custom encryption key for your application, set the environment variable `PWPUSH_MASTER_KEY`: - - PWPUSH_MASTER_KEY=0c110f7f9d93d2122f36debf8a24bf835f33f248681714776b336849b801f693 - -## Generate a New Encryption Key - -Key generation can be done through the [helper tool](https://pwpush.com/pages/generate_key) or on the command line in the application source using `Lockbox.generate_key`: - -```ruby -bundle -rails c -Lockbox.generate_key -``` - -Notes: - -* If an encryption key isn't provided, a default key will be used. -* The best security for private instances of Password Pusher is to use your own custom encryption key although it is not required. -* The risk in using the default key is lessened if you keep your instance secure and your push expirations short. e.g. 1 day/1 view versus 100 days/100 views. -* Once a push expires, all encrypted data is deleted. -* Changing an encryption key where old pushes already exist will make those older pushes unreadable. In other words, the payloads will be garbled. New pushes going forward will work fine. - - -# Changing Application Defaults - -## Application General - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__DEFAULT_LOCALE | Sets the default language for the application. See the [documentation](https://github.com/pglombardo/PasswordPusher#internationalization). | `en` | -| PWP__RELATIVE_ROOT | Runs the application in a subfolder. e.g. With a value of `pwp` the front page will then be at `https://url/pwp` | `Not set` | -| PWP__SHOW_VERSION | Show the version in the footer | `true` | -| PWP__SHOW_GDPR_CONSENT_BANNER | Optionally enable or disable the GDPR cookie consent banner. | `true` | -| PWP__TIMEZONE | Set the application wide timezone. Use a valid timezone string (see note below). | `America/New_York` | -| SECRET_KEY_BASE | A secret key that is used for various security-related features, including session cookie encryption and other cryptographic operations. Use `/opt/PasswordPusher/bin/rails secret` to generate a random key string. [See the SECRET_KEY_BASE wiki page.](https://github.com/pglombardo/PasswordPusher/wiki/SECRET_KEY_BASE)| _Randomly Generated on boot_ | - -_Note_: The list of valid timezone strings can be found at [https://en.wikipedia.org/wiki/List_of_tz_database_time_zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). - -## Password Push Expiration Settings - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__PW__EXPIRE_AFTER_DAYS_DEFAULT | Controls the "Expire After Days" default value in Password#new | `7` | -| PWP__PW__EXPIRE_AFTER_DAYS_MIN | Controls the "Expire After Days" minimum value in Password#new | `1` | -| PWP__PW__EXPIRE_AFTER_DAYS_MAX | Controls the "Expire After Days" maximum value in Password#new | `90` | -| PWP__PW__EXPIRE_AFTER_VIEWS_DEFAULT | Controls the "Expire After Views" default value in Password#new | `5` | -| PWP__PW__EXPIRE_AFTER_VIEWS_MIN | Controls the "Expire After Views" minimum value in Password#new | `1` | -| PWP__PW__EXPIRE_AFTER_VIEWS_MAX | Controls the "Expire After Views" maximum value in Password#new | `100` | -| PWP__PW__ENABLE_DELETABLE_PUSHES | Can passwords be deleted by viewers? When true, passwords will have a link to optionally delete the password being viewed | `false` | -| PWP__PW__DELETABLE_PUSHES_DEFAULT | When the above is `true`, this sets the default value for the option. | `true` | -| PWP__PW__ENABLE_RETRIEVAL_STEP | When `true`, adds an option to have a preliminary step to retrieve passwords. | `true` | -| PWP__PW__RETRIEVAL_STEP_DEFAULT | Sets the default value for the retrieval step for newly created passwords. | `false` | -| PWP__PW__ENABLE_BLUR | Enables or disables the 'blur' effect when showing a push payload to the user. | `true` | - - -## Password Generator Settings - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__GEN__HAS_NUMBERS | Controls whether generated passwords have numbers | `true` | -| PWP__GEN__TITLE_CASED | Controls whether generated passwords will be title cased | `true` | -| PWP__GEN__USE_SEPARATORS | Controls whether generated passwords will use separators between syllables | `true` | -| PWP__GEN__CONSONANTS | The list of consonants to generate from | `bcdfghklmnprstvz` | -| PWP__GEN__VOWELS | The list of vowels to generate from | `aeiouy` | -| PWP__GEN__SEPARATORS | If `use_separators` is enabled above, the list of separators to use (randomly) | `-_=` | -| PWP__GEN__MAX_SYLLABLE_LENGTH | The maximum length of each syllable that a generated password can have | `3` | -| PWP__GEN__MIN_SYLLABLE_LENGTH | The minimum length of each syllable that a generated password can have | `1` | -| PWP__GEN__SYLLABLES_COUNT | The exact number of syllables that a generated password will have | `3` | - -# Enabling Logins - -To enable logins in your instance of Password Pusher, you must have an SMTP server available to send emails through. These emails are sent for events such as password reset, unlock, registration etc.. - -To use logins, you should be running a database backed version of Password Pusher. Logins will likely work in an ephemeral setup but aren't suggested since all data is wiped with every restart. - -_All_ of the following environments need to be set (except SMTP authentication if none) for application logins to function properly. - -| Environment Variable | Description | Default | -| --------- | ------------------ | --- | -| PWP__ENABLE_LOGINS | On/Off switch for logins. | `false` | -| PWP__ALLOW_ANONYMOUS | When false, requires a login for the front page (to push new passwords). | `true` | -| PWP__MAIL__RAISE_DELIVERY_ERRORS | Email delivery errors will be shown in the application | `true` | -| PWP__MAIL__SMTP_ADDRESS | Allows you to use a remote mail server. Just change it from its default "localhost" setting. | `smtp.domain.com` | -| PWP__MAIL__SMTP_PORT | Port of the SMTP server | `587` | -| PWP__MAIL__SMTP_USER_NAME | If your mail server requires authentication, set the username in this setting. | `smtp_username` | -| PWP__MAIL__SMTP_PASSWORD | If your mail server requires authentication, set the password in this setting. | `smtp_password` | -| PWP__MAIL__SMTP_AUTHENTICATION | If your mail server requires authentication, you need to specify the authentication type here. This is a string and one of :plain (will send the password in the clear), :login (will send 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) | `plain` | -| PWP__MAIL__SMTP_STARTTLS | Use STARTTLS when connecting to your SMTP server and fail if unsupported. | `true` | -| PWP__MAIL__SMTP_ENABLE_STARTTLS_AUTO | Detects if STARTTLS is enabled in your SMTP server and starts to use it | `true` | -| PWP__MAIL__OPEN_TIMEOUT | Number of seconds to wait while attempting to open a connection. | `10` | -| PWP__MAIL__READ_TIMEOUT | Number of seconds to wait until timing-out a read(2) call. | `10` | -| PWP__HOST_DOMAIN | Used to build fully qualified URLs in emails. Where is your instance hosted? | `pwpush.com` | -| PWP__HOST_PROTOCOL | The protocol to access your Password Pusher instance. HTTPS advised. | `https` | -| PWP__MAIL__MAILER_SENDER | This is the "From" address in sent emails. | '"Company Name" ' | -| PWP__DISABLE_SIGNUPS| Once your user accounts are created, you can set this to disable any further user account creation. Sign up links and related backend functionality is disabled when `true`. | `false` | -| PWP__SIGNUP_EMAIL_REGEXP | The regular expression used to validate emails for new user signups. This can be modified to limit new account creation to a subset of domains. e.g. \A[^@\s]+@(hey\.com\|gmail\.com)\z. _Tip: use https://rubular.com to test out your regular expressions. It includes a guide to what each component means in regexp._ | `\A[^@\s]+@[^@\s]+\z` | - -## Shell Example - -``` -export PWP__ENABLE_LOGINS=true -export PWP__MAIL__RAISE_DELIVERY_ERRORS=true -export PWP__MAIL__SMTP_ADDRESS=smtp.mycompany.org -export PWP__MAIL__SMTP_PORT=587 -export PWP__MAIL__SMTP_USER_NAME=yolo -export PWP__MAIL__SMTP_PASSWORD=secret -export PWP__MAIL__SMTP_AUTHENTICATION=plain -export PWP__MAIL__SMTP_STARTTLS=true -export PWP__MAIL__OPEN_TIMEOUT=10 -export PWP__MAIL__READ_TIMEOUT=10 -export PWP__HOST_DOMAIN=pwpush.mycompany.org -export PWP__HOST_PROTOCOL=https -export PWP__MAIL__MAILER_SENDER='"Spiderman" ' -``` - -* See also this [Github discussion](https://github.com/pglombardo/PasswordPusher/issues/265#issuecomment-964432942). -* [External Documentation on mailer configuration](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration) for the underlying technology if you need more details for configuration issues. - -# Enabling File Pushes - -To enable file uploads (File Pushes) in your instance of Password Pusher, there are a few requirements: - -1. you must have logins enabled (see above) -2. specify a place to store uploaded files -3. If you use cloud storage, configure the CORS configuration in your buckets (detailed below) - -The following settings enable/disable the feature and specify where to store uploaded files. - -This feature can store uploads on local disk (not valid for Docker containers), Amazon S3, Google Cloud Storage or Azure Storage. - -## General Settings - -| Environment Variable | Description | Value(s) | -| --------- | ------------------ | --- | -| PWP__ENABLE_FILE_PUSHES | On/Off switch for File Pushes. | `false` | -| PWP__FILES__STORAGE | Chooses the storage area for uploaded files. | `local`, `amazon`, `google` or `microsoft` | -| PWP__FILES__ENABLE_BLUR | Enables or disables the 'blur' effect when showing a text payload to the user. | `true` | -| PWP__FILES__ENABLE_DELETABLE_PUSHES | Can passwords be deleted by viewers? When true, passwords will have a link to optionally delete the password being viewed | `false` | -| PWP__FILES__DELETABLE_PUSHES_DEFAULT | When the above is `true`, this sets the default value for the option. | `true` | -| PWP__FILES__ENABLE_RETRIEVAL_STEP | When `true`, adds an option to have a preliminary step to retrieve passwords. | `true` | -| PWP__FILES__RETRIEVAL_STEP_DEFAULT | Sets the default value for the retrieval step for newly created passwords. | `false` | -| PWP__FILES__MAX_FILE_UPLOADS | Sets the maximum number of files that can be added to a single push. | `10` | - -## File Push Expiration Settings - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__FILES__EXPIRE_AFTER_DAYS_DEFAULT | Controls the "Expire After Days" default value in Password#new | `7` | -| PWP__FILES__EXPIRE_AFTER_DAYS_MIN | Controls the "Expire After Days" minimum value in Password#new | `1` | -| PWP__FILES__EXPIRE_AFTER_DAYS_MAX | Controls the "Expire After Days" maximum value in Password#new | `90` | -| PWP__FILES__EXPIRE_AFTER_VIEWS_DEFAULT | Controls the "Expire After Views" default value in Password#new | `5` | -| PWP__FILES__EXPIRE_AFTER_VIEWS_MIN | Controls the "Expire After Views" minimum value in Password#new | `1` | -| PWP__FILES__EXPIRE_AFTER_VIEWS_MAX | Controls the "Expire After Views" maximum value in Password#new | `100` | - -## Local Storage - -`PWP__FILES__STORAGE=local` - -The default location for local storage is `./storage`. - -If using containers and you prefer local storage, you can add a volume mount to the container at the path `/opt/PasswordPusher/storage`: - -`docker run -d -p "5100:5100" -v /var/lib/pwpush/files:/opt/PasswordPusher/storage pglombardo/pwpush:release` - -Please _make sure_ that the directory is writeable by the docker container. - -A CORS configuration is not required for local storage. - -## Amazon S3 - -To configure the application to store files in an Amazon S3 bucket, you have to: - -1. set the required environment variables detailed below (or the equivalent values in `settings.yml`) -2. apply a CORS configuration to your S3 bucket (see next section) - -| Environment Variable | Description | Value(s) | -| --------- | ------------------ | --- | -| PWP__FILES__STORAGE | Storage Provider Selection | `amazon` | -| PWP__FILES__S3__ENDPOINT | S3 Endpoint | None | -| PWP__FILES__S3__ACCESS_KEY_ID | Access Key ID | None | -| PWP__FILES__S3__SECRET_ACCESS_KEY | Secret Access Key| None | -| PWP__FILES__S3__REGION | S3 Region| None | -| PWP__FILES__S3__BUCKET | The S3 bucket name | None | - -### Amazon S3 CORS Configuration - -The application performs direct uploads from the browser to your Amazon S3 bucket. This provides better performance and reduces load on the application itself. - -For this to work, you have to add a CORS configuration to your bucket. - -This direct upload functionality is done using a library called ActiveStorage. For the full documentation on configuring CORS for ActiveStorage, [see here](https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration). - -```json -[ - { - "AllowedHeaders": [ - "Content-Type", - "Content-MD5", - "Content-Disposition" - ], - "AllowedMethods": [ - "PUT" - ], - "AllowedOrigins": [ - "https://www.example.com" << Change to your URL - ], - "MaxAgeSeconds": 3600 - } -] -``` -## Google Cloud Storage - -To configure the application to store files in Google Cloud Storage, you have to: - -1. set the required environment variables detailed below (or the equivalent values in `settings.yml`) -2. apply a CORS configuration (see next section) - -| Environment Variable | Description | Value(s) | -| --------- | ------------------ | --- | -| PWP__FILES__STORAGE | Storage Provider Selection | `google` | -| PWP__FILES__GCS__PROJECT | GCS Project | None | -| PWP__FILES__GCS__CREDENTIALS | GCS Credentials | None | -| PWP__FILES__GCS__BUCKET | The GCS bucket name | None | - -### Google Cloud Storage CORS Configuration - -The application performs direct uploads from the browser to Google Cloud Storage. This provides better performance and reduces load on the application itself. - -For this to work, you have to add a CORS configuration. - -This direct upload functionality is done using a library called ActiveStorage. For the full documentation on configuring CORS for ActiveStorage, [see here](https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration). - -```json -[ - { - "origin": ["https://www.example.com"], - "method": ["PUT"], - "responseHeader": ["Content-Type", "Content-MD5", "Content-Disposition"], - "maxAgeSeconds": 3600 - } -] -``` - - -## Azure Storage - -To configure the application to store files in Azure Storage, you have to: - -1. set the required environment variables detailed below (or the equivalent values in `settings.yml`) -2. apply a CORS configuration (see next section) - -| Environment Variable | Description | Value(s) | -| --------- | ------------------ | --- | -| PWP__FILES__STORAGE | Storage Provider Selection | `microsoft` | -| PWP__FILES__AS__STORAGE_ACCOUNT_NAME | Azure Storage Account Name | None | -| PWP__FILES__AS__STORAGE_ACCESS_KEY | Azure Storage Account Key | None | -| PWP__FILES__AS__CONTAINER | Azure Storage Container Name | None | - -### Azure Storage CORS Configuration - -The application performs direct uploads from the browser to Azure Storage. This provides better performance and reduces load on the application itself. - -For this to work, you have to add a CORS configuration. - -This direct upload functionality is done using a library called ActiveStorage. For the full documentation on configuring CORS for ActiveStorage, [see here](https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration). - -```xml - - - https://www.example.com - PUT - Content-Type, Content-MD5, x-ms-blob-content-disposition, x-ms-blob-type - 3600 - - -``` - -# Enabling URL Pushes - -Similar to file pushes, URL pushes also require logins to be enabled. - -| Environment Variable | Description | Default | -| --------- | ------------------ | --- | -| PWP__ENABLE_URL_PUSHES | On/Off switch for URL Pushes. | `false` | - -## URL Push Expiration Settings - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__URL__EXPIRE_AFTER_DAYS_DEFAULT | Controls the "Expire After Days" default value in Password#new | `7` | -| PWP__URL__EXPIRE_AFTER_DAYS_MIN | Controls the "Expire After Days" minimum value in Password#new | `1` | -| PWP__URL__EXPIRE_AFTER_DAYS_MAX | Controls the "Expire After Days" maximum value in Password#new | `90` | -| PWP__URL__EXPIRE_AFTER_VIEWS_DEFAULT | Controls the "Expire After Views" default value in Password#new | `5` | -| PWP__URL__EXPIRE_AFTER_VIEWS_MIN | Controls the "Expire After Views" minimum value in Password#new | `1` | -| PWP__URL__EXPIRE_AFTER_VIEWS_MAX | Controls the "Expire After Views" maximum value in Password#new | `100` | -| PWP__URL__ENABLE_DELETABLE_PUSHES | Can passwords be deleted by viewers? When true, passwords will have a link to optionally delete the password being viewed | `false` | -| PWP__URL__DELETABLE_PUSHES_DEFAULT | When the above is `true`, this sets the default value for the option. | `true` | -| PWP__URL__ENABLE_RETRIEVAL_STEP | When `true`, adds an option to have a preliminary step to retrieve passwords. | `true` | -| PWP__URL__RETRIEVAL_STEP_DEFAULT | Sets the default value for the retrieval step for newly created passwords. | `false` | - -# Rebranding - -Password Pusher has the ability to be [re-branded](https://twitter.com/pwpush/status/1557658305325109253) with your own site title, tagline and logo. - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/branding%2Fpwpush-brand-example.png) - -This can be done with the following environment variables: - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__BRAND__TITLE | Title for the site. | `Password Pusher` | -| PWP__BRAND__TAGLINE | Tagline for the site. | `Go Ahead. Email Another Password.` | -| PWP__BRAND__DISCLAIMER | Disclaimer for the site. | `Undefined` | -| PWP__BRAND__SHOW_FOOTER_MENU | On/Off switch for the footer menu. | `true` | -| PWP__BRAND__LIGHT_LOGO | Site logo image for the light theme. | `logo-transparent-sm-bare.png` | -| PWP__BRAND__DARK_LOGO | Site logo image for the dark theme. | `logo-transparent-sm-bare.png` | - -The values for the `*_LOGO` images can either be: - -1. Fully qualified HTTP(s) URLS such as `https://pwpush.fra1.cdn.digitaloceanspaces.com/dev%2Facme-logo.jpg` (easiest) -2. Relative path that is mounted inside the container - -As an example for #2 above, say you place your logo images locally into `/var/lib/pwpush/logos/`. You would then mount that directory into the container: - -`docker run -d -p "5100:5100" -v /var/lib/pwpush/logos:/opt/PasswordPusher/public/logos pglombardo/pwpush:release` - -or alternatively for a `docker-compose.yml` file: - -```yaml -volumes: - # Example of a persistent volume for the storage directory (file uploads) - - /var/lib/pwpush/logos:/opt/PasswordPusher/public/logos:r -``` - -See [here](https://github.com/pglombardo/PasswordPusher/blob/master/containers/docker/docker-compose-postgres.yml) for a larger Docker Compose explanation. - -With this setup, you can then set your `LOGO` environment variables (or `settings.yml` options) to: - -``` -PWP__BRAND__LIGHT_LOGO=/logos/mylogo.png -``` - -## See Also - -* the `brand` section of [settings.yml](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml) for more details, examples and description. -* [this issue comment](https://github.com/pglombardo/PasswordPusher/issues/432#issuecomment-1282158006) on how to mount images into the contianer and set your environment variables accordingly - -# Change the Default Language - -The application comes with more than 24 languages bundled in which are selectable inside the application. The default language of the application is English. If you would like to change this default language, simply set the following environment variable for your application. - -```PWP__DEFAULT_LOCALE=is``` - -A list of supported languages (and their language codes) can be found in the [settings.yml](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml#L702-L734) file under `language_codes`. - -Choose which language you would like to have as the default language, and use the two letter code as the value for the environment variable. - -# Themes - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fquartz-theme-pwpush.com.png) - -Password Pusher supports **26 themes out of the box**. These themes are taken directly from the great [Bootswatch](https://bootswatch.com) project and are unmodified. - -As such, themes mostly work although there may be a rare edge cases where fonts may not be clear or something doesn't display correctly. If this is the case you can add custom CSS styles to fix any such issues. See the next section on how to add custom styling. - ----> Checkout the [Themes Gallery](Themes.md)! - -The Bootswatch themes are licensed under the MIT license. - -## Configuring a Theme - -To specify a theme for your Password Pusher instance, you must set __two__ environment variables:the `PWP__THEME` environment variable to specify the theme and `PWP_PRECOMPILE=true` environment variable to have CSS assets recompiled on container boot. - -**Make sure to set both `PWP__THEME` and `PWP_PRECOMPILE` for the selected theme to work.** 👍 - -| Environment Variable | Description | Possible Values | -| --------- | ------------------ | --- | -| PWP__THEME | Theme used for the application. | 'cerulean', 'cosmo', 'cyborg', 'darkly', 'flatly', 'journal', 'litera', 'lumen', 'lux', 'materia', 'minty', 'morph', 'pulse', 'quartz', 'sandstone', 'simplex', 'sketchy', 'slate', 'solar', 'spacelab', 'superhero', 'united', 'vapor', 'yeti', 'zephyr' | -| PWP_PRECOMPILE | Forces a rebuild of the theme CSS on boot. | `true` | - ----> See the [Themes Gallery](Themes.md) for examples of each. - -__Note:__ Since the theme is a boot level selection, the theme can only be selected by setting the `PWP__THEME` environment variable (and not modifying `settings.yml`). - -So to set the `quartz` theme for a Docker container: - -```bash -docker run --env PWP__THEME=quartz --env PWP_PRECOMPILE=true -p "5100:5100" pglombardo/pwpush:release -``` - -or alternatively for source code: - -```bash -export PWP__THEME=quartz -bin/rails asset:precompile # manually recompile assets -bin/rails server -``` - -## How to Precompile CSS Assets - -Password Pusher has a pre-compilation step of assets. This is used to fingerprint assets and pre-process CSS code for better performance. - -If using Docker containers, you can simply set the `PWP_PRECOMPILE=true` environment variable. On container boot, all assets will be precompiled and bundled into `/assets`. - -__Note: Precompiling all application assets for a new theme on container boot can add 30-90 seconds to the boot process (depending on the system). Make sure to allow this time in your health checks before declaring the container as unresponsive.__ - -To manually precompile assets run `bin/rails assets:precompile`. - -## Adding an entirely new theme from scratch - -The `PWP__THEME` environment variable simply causes the application to load a css file from `app/assets/stylesheets/themes/{$PWP__THEME}.css`. If you were to place a completely custom CSS file into that directory, you could then set the `PWP__THEME` environment variable to the filename that you added. - -For example: - -Add `app/assets/stylesheets/themes/mynewtheme.css` and set `PWP__THEME=mynewtheme`. - -This would cause that CSS file to be loaded and used as the theme for the site. Please refer to existing themes if you would like to author your theme for Password Pusher. - -Remember that after the new theme is configured, assets must be precompiled again. See the the previous section for instructions - -# How to Add Custom CSS - -Password Pusher supports adding custom CSS to the application. The application hosts a `custom.css` file located at `app/assets/stylesheets/custom.css`. This file is loaded last so it take precedence over all built in themes and styling. - -This file can either be modified directly or in the case of Docker containers, a new file mounted over the existing one. - -When changing this file inside a Docker container, make sure to set the precompile option `PWP_PRECOMPILE=true`. This will assure that the custom CSS is incorporated correctly. - -An example Docker command to override that file would be: - -``` -docker run -e PWP_PRECOMPILE=true --mount type=bind,source=/path/to/my/custom.css,target=/opt/PasswordPusher/app/assets/stylesheets/custom.css -p 5100:5100 pglombardo/pwpush:release -``` -or the `docker-compose.yml` equivalent: - -``` -version: '2.1' -services: - - pwpush: - image: docker.io/pglombardo/pwpush:release - ports: - - "5100:5100" - environment: - PWP_PRECOMPILE: 'true' - volumes: - - type: bind - source: /path/to/my/custom.css - target: /opt/PasswordPusher/app/assets/stylesheets/custom.css -``` - -Remember that when doing this, this new CSS code has to be precompiled. - -To do this in Docker containers, simply set the environment variable `PWP_PRECOMPILE=true`. For source code, run `bin/rails assets:precompile`. This compilation process will incorporate the custom CSS into the updated site theme. - -# Google Analytics - -| Environment Variable | Description | -| --------- | ------------------ | -| GA_ENABLE | The existence of this variable will enable the Google Analytics for the application. See `app/views/layouts/_ga.html.erb`.| -| GA_ACCOUNT | The Google Analytics account id. E.g. `UA-XXXXXXXX-X` | -| GA_DOMAIN | The domain where the application is hosted. E.g. `pwpush.com` | - -# Throttling - -Throttling enforces a minimum time interval -between subsequent HTTP requests from a particular client, as -well as by defining a maximum number of allowed HTTP requests -per a given time period (per second, minute, hourly, or daily). - -| Environment Variable | Description | Default Value | -| --------- | ------------------ | --- | -| PWP__THROTTLING__DAILY | The maximum number of allowed HTTP requests per day | `5000` | -| PWP__THROTTLING__HOURLY | The maximum number of allowed HTTP requests per hour | `600` | -| PWP__THROTTLING__MINUTE | The maximum number of allowed HTTP requests per minute | `60` | -| PWP__THROTTLING__SECOND | The maximum number of allowed HTTP requests per second | `20` | - - -# Logging - -| Environment Variable | Description | -| --------- | ------------------ | -| PWP__LOG_LEVEL | Set the logging level for the application. Valid values are: `debug`, `info`, `warn`, `error` and `fatal`. Note: lowercase. -| PWP__LOG_TO_STDOUT | Set to 'true' to have log output sent to STDOUT instead of log files. Default: `false` - - -# Forcing SSL Links - -See also the Proxies section below. - -| Environment Variable | Description | -| --------- | ------------------ | -| FORCE_SSL | (Deprecated) The existence of this variable will set `config.force_ssl` to `true` and generate HTTPS based secret URLs - -__Note:__ This is a legacy setting and is no longer suggested for use. If using a proxy, make sure to have your proxy forward the `X-Forwarded-Host`, `X-Forwarded-Port` and `X-Forwarded-Proto` HTTP headers. See the "Proxies" section for more information and instructions. -_ -# Proxies - -An occasional issue is that when using Password Pusher behind a proxy, the generated secret URLs are incorrect. They often have the backend URL & port instead of the public fully qualified URL - or use HTTP instead of HTTPS (or all of the preceding). - -To resolve this, make sure your proxy properly forwards the `X-Forwarded-Host`, `X-Forwarded-Port` and `X-Forwarded-Proto` headers. - -The values in these headers represent the front end request. When these headers are sent, Password Pusher can then build the correct URLs. - -## Nginx Example - -As an example, for nginx, the addition could be: - -```nginx -proxy_set_header X-Forwarded-Port $server_port; -proxy_set_header X-Forwarded-Host $host; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Ssl on; -``` - -If you are unable to have these headers passed to the application for any reason, you could instead force an override of the base URL using the `PWP__OVERRIDE_BASE_URL` environment variable. - -| Environment Variable | Description | Example Value | -| --------- | ------------------ | --- | -| PWP__OVERRIDE_BASE_URL | Set this value (without a trailing slash) to force the base URL of generated links. | 'https://subdomain.domain.dev' +Thanks for using Password Pusher! diff --git a/Gemfile b/Gemfile index d876469821b..3bfba51f15f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,45 +1,56 @@ # frozen_string_literal: true -source 'https://rubygems.org' +source "https://rubygems.org" -ruby ENV['CUSTOM_RUBY_VERSION'] || '>=3.1.4' +ruby ENV["CUSTOM_RUBY_VERSION"] || ">=3.4.3" -gem 'rails', '~> 7.1.3' +gem "rails", "~> 7.2.2" group :development do - gem 'listen' + gem "listen" # Visual Studio Additions - gem 'rubocop' - gem 'rubocop-performance' - gem 'rubocop-rails' - gem 'ruby-debug-ide' + gem "ruby-debug-ide" + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + # gem install debase -v '0.2.5.beta2' -- --with-cflags=-Wno-error=incompatible-function-pointer-types + # https://blog.arkency.com/how-to-get-burned-by-16-years-old-hack-in-2024/ + gem "debase", ">= 0.2.5.beta2", platforms: %i[mri mingw x64_mingw] + + gem "pry-rails" + gem "web-console" - gem 'pry-rails' + # A fully configurable and extendable Git hook manager + gem "overcommit", require: false - # Access an interactive console on exception pages or by - # calling 'console' anywhere in the code. - gem 'web-console', '>= 4.2.0' + gem "mailbin" end group :test do # Adds support for Capybara system testing and selenium driver - gem 'capybara', '>= 3.37.1', '< 4.0' - gem 'minitest' - gem 'minitest-rails', '>= 6.1.0' - gem 'minitest-reporters' - gem 'selenium-webdriver' - gem 'webdrivers', '~> 5.3', require: false + gem "capybara", ">= 3.37.1", "< 4.0" + gem "minitest" + gem "minitest-rails", ">= 6.1.0" + gem "minitest-reporters" + gem "selenium-webdriver", ">= 4.20.1" end group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem 'debase', '>= 0.2.5.beta2', platforms: %i[mri mingw x64_mingw] - gem 'debug', platforms: %i[mri mingw x64_mingw] + gem "debug", platforms: %i[mri windows], require: "debug/prelude" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false + + gem "i18n-tasks", "~> 1.0.15", require: false + + gem "erb_lint", "~> 0.9.0", require: false + gem "standardrb", "~> 1.0" end -gem 'rack-attack' -gem 'rack-cors' +gem "rack-cors" # OSX: ../src/utils.h:33:10: fatal error: 'climits' file not found # From: @@ -53,70 +64,73 @@ gem 'rack-cors' # $ bundle install # gem 'therubyracer' # -gem 'high_voltage' -gem 'kramdown', require: false -gem 'lockbox' +gem "high_voltage" +gem "kramdown", require: false +gem "lockbox" # Reduces boot times through caching; required in config/boot.rb -gem 'bootsnap', '>= 1.4.4', require: false +gem "bootsnap", require: false # Use SCSS for stylesheets -gem 'sass-rails', '~> 6.0', '>= 6.0.0' -gem 'terser', '~> 1.2' +gem "sass-rails", "~> 6.0", ">= 6.0.0" +gem "cssbundling-rails", "~> 1.4" +gem "terser", "~> 1.2" # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder -gem 'bootstrap', '5.2.3' -gem 'json', '~> 2.7' # Legacy carry-over -gem 'will_paginate', '~> 4.0.0' -gem 'will_paginate-bootstrap-style' +gem "bootstrap" +gem "json", "~> 2.12" # Legacy carry-over # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] -gem 'turbo-rails' +gem "turbo-rails" # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] -gem 'stimulus-rails' +gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] -gem 'jbuilder' +gem "jbuilder" # Use Redis adapter to run Action Cable in production # gem 'redis', '~> 4.0' # Use ActiveModel has_secure_password # gem 'bcrypt', '~> 3.1.7' -gem 'apipie-rails' -gem 'config' -gem 'devise', '>= 4.9.0' -gem 'foreman' -gem 'lograge' -gem 'mail_form', '>= 1.9.0' -gem 'oj' -gem 'puma' -gem 'rollbar' -gem 'simple_token_authentication' - -gem 'devise-i18n' -gem 'i18n-tasks', '~> 1.0.13' # , group: :development -gem 'rails-i18n', '~> 7.0.8' -gem 'route_translator', '>= 13.0.0' -gem 'translation' +gem "apipie-rails" +gem "config" +gem "devise", ">= 4.9.0" +gem "foreman" +gem "lograge" +gem "mail_form", ">= 1.9.0" +gem "oj" +gem "puma" +gem "kaminari", "~> 1.2" +gem "invisible_captcha", "~> 2.3" + +gem "devise-i18n" +gem "rails-i18n", "~> 7.0.10" +gem "translation" # For File Uploads -gem 'aws-sdk-s3', require: false -gem 'azure-storage-blob', '~> 2.0', require: false -gem 'google-cloud-storage', '~> 1.48', require: false +gem "aws-sdk-s3", require: false +gem "azure-storage-blob", "~> 2.0", require: false +gem "google-cloud-storage", "~> 1.56", require: false # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] +gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] # Database backends -gem 'mysql2' -gem 'pg' -gem 'sqlite3', force_ruby_platform: true +gem "mysql2" +gem "pg" +gem "sqlite3", force_ruby_platform: true -group :production do - gem 'rack-throttle', '0.7.0' - gem 'rack-timeout' +group :production, :development do + gem "rack-attack" end -gem 'version', git: 'https://github.com/pglombardo/version.git', branch: 'master' +gem "rollbar" +gem "version", git: "https://github.com/pglombardo/version.git", branch: "master" +gem "administrate", "~> 0.20.1" +gem "rqrcode", "~> 3.1" +gem "turnout2024", require: "turnout" + +gem "solid_queue", "~> 1.1" + +gem "mission_control-jobs", "~> 1.0.2" -gem 'derailed_benchmarks', group: :development -gem 'stackprof', group: :development +gem "overmind", "~> 2.5", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index a68ca844f91..63ab2dae6dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,104 +8,109 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3) - actionpack (= 7.1.3) - activesupport (= 7.1.3) + actioncable (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3) - actionpack (= 7.1.3) - activejob (= 7.1.3) - activerecord (= 7.1.3) - activestorage (= 7.1.3) - activesupport (= 7.1.3) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3) - actionpack (= 7.1.3) - actionview (= 7.1.3) - activejob (= 7.1.3) - activesupport (= 7.1.3) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) + mail (>= 2.8.0) + actionmailer (7.2.2.1) + actionpack (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activesupport (= 7.2.2.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3) - actionview (= 7.1.3) - activesupport (= 7.1.3) + actionpack (7.2.2.1) + actionview (= 7.2.2.1) + activesupport (= 7.2.2.1) nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.2) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3) - actionpack (= 7.1.3) - activerecord (= 7.1.3) - activestorage (= 7.1.3) - activesupport (= 7.1.3) + useragent (~> 0.16) + actiontext (7.2.2.1) + actionpack (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3) - activesupport (= 7.1.3) + actionview (7.2.2.1) + activesupport (= 7.2.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3) - activesupport (= 7.1.3) + activejob (7.2.2.1) + activesupport (= 7.2.2.1) globalid (>= 0.3.6) - activemodel (7.1.3) - activesupport (= 7.1.3) - activerecord (7.1.3) - activemodel (= 7.1.3) - activesupport (= 7.1.3) + activemodel (7.2.2.1) + activesupport (= 7.2.2.1) + activerecord (7.2.2.1) + activemodel (= 7.2.2.1) + activesupport (= 7.2.2.1) timeout (>= 0.4.0) - activestorage (7.1.3) - actionpack (= 7.1.3) - activejob (= 7.1.3) - activerecord (= 7.1.3) - activesupport (= 7.1.3) + activestorage (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activesupport (= 7.2.2.1) marcel (~> 1.0) - activesupport (7.1.3) + activesupport (7.2.2.1) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + administrate (0.20.1) + actionpack (>= 6.0, < 8.0) + actionview (>= 6.0, < 8.0) + activerecord (>= 6.0, < 8.0) + jquery-rails (~> 4.6.0) + kaminari (~> 1.2.2) + sassc-rails (~> 2.1) + selectize-rails (~> 0.6) ansi (1.5.0) - apipie-rails (1.3.0) + apipie-rails (1.4.2) actionpack (>= 5.0) activesupport (>= 5.0) - ast (2.4.2) - autoprefixer-rails (10.4.16.0) - execjs (~> 2) - aws-eventstream (1.3.0) - aws-partitions (1.889.0) - aws-sdk-core (3.191.1) + ast (2.4.3) + aws-eventstream (1.4.0) + aws-partitions (1.1126.0) + aws-sdk-core (3.226.2) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.77.0) - aws-sdk-core (~> 3, >= 3.191.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.143.0) - aws-sdk-core (~> 3, >= 3.191.0) + logger + aws-sdk-kms (1.106.0) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.192.0) + aws-sdk-core (~> 3, >= 3.225.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) @@ -115,25 +120,25 @@ GEM faraday_middleware (~> 1.0, >= 1.0.0.rc1) net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark-ips (2.13.0) - better_html (2.0.2) + benchmark (0.4.1) + better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.6) + bigdecimal (3.2.2) bindex (0.8.1) - bootsnap (1.18.3) + bootsnap (1.18.6) msgpack (~> 1.2) - bootstrap (5.2.3) - autoprefixer-rails (>= 9.1.0) - popper_js (>= 2.11.6, < 3) - sassc-rails (>= 2.0.0) - builder (3.2.4) + bootstrap (5.3.5) + popper_js (>= 2.11.8, < 3) + brakeman (7.0.2) + racc + builder (3.3.0) capybara (3.40.0) addressable matrix @@ -143,83 +148,52 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) + chunky_png (1.4.0) coderay (1.1.3) - concurrent-ruby (1.2.3) - config (5.1.0) + concurrent-ruby (1.3.5) + config (5.5.2) deep_merge (~> 1.2, >= 1.2.1) - dry-validation (~> 1.0, >= 1.0.0) - connection_pool (2.4.1) + ostruct + connection_pool (2.5.3) crass (1.0.6) - date (3.3.4) - dead_end (4.0.0) - debase (0.2.5.beta2) - debase-ruby_core_source (>= 0.10.12) - debase-ruby_core_source (3.3.1) - debug (1.9.1) + cssbundling-rails (1.4.3) + railties (>= 6.0.0) + date (3.4.1) + debase (0.2.9) + debase-ruby_core_source (>= 3.4.1) + debase-ruby_core_source (3.4.1) + debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) declarative (0.0.20) deep_merge (1.2.2) - derailed_benchmarks (2.1.2) - benchmark-ips (~> 2) - dead_end - get_process_mem (~> 0) - heapy (~> 0) - memory_profiler (>= 0, < 2) - mini_histogram (>= 0.3.0) - rack (>= 1) - rack-test - rake (> 10, < 14) - ruby-statistics (>= 2.1) - thor (>= 0.19, < 2) - devise (4.9.3) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-i18n (1.12.0) + devise-i18n (1.14.0) devise (>= 4.9.0) - digest-crc (0.6.5) + rails-i18n + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - drb (2.2.0) - ruby2_keywords - dry-configurable (1.1.0) - dry-core (~> 1.0, < 2) - zeitwerk (~> 2.6) - dry-core (1.0.1) - concurrent-ruby (~> 1.0) - zeitwerk (~> 2.6) - dry-inflector (1.0.0) - dry-initializer (3.1.1) - dry-logic (1.5.0) - concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) - zeitwerk (~> 2.6) - dry-schema (1.13.3) - concurrent-ruby (~> 1.0) - dry-configurable (~> 1.0, >= 1.0.1) - dry-core (~> 1.0, < 2) - dry-initializer (~> 3.0) - dry-logic (>= 1.4, < 2) - dry-types (>= 1.7, < 2) - zeitwerk (~> 2.6) - dry-types (1.7.2) - bigdecimal (~> 3.0) - concurrent-ruby (~> 1.0) - dry-core (~> 1.0) - dry-inflector (~> 1.0) - dry-logic (~> 1.4) - zeitwerk (~> 2.6) - dry-validation (1.10.0) - concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) - dry-initializer (~> 3.0) - dry-schema (>= 1.12, < 2) - zeitwerk (~> 2.6) - erubi (1.12.0) - execjs (2.9.1) - faraday (1.10.3) + drb (2.2.3) + erb (5.0.1) + erb_lint (0.9.0) + activesupport + better_html (>= 2.0.1) + parser (>= 2.7.1.4) + rainbow + rubocop (>= 1) + smart_properties + erubi (1.13.1) + et-orbi (1.2.11) + tzinfo + execjs (2.10.0) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -232,23 +206,24 @@ GEM faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-multipart (1.1.1) + 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) - faraday_middleware (1.2.0) + faraday_middleware (1.2.1) faraday (~> 1.0) - ffi (1.16.3) - foreman (0.87.2) + ffi (1.17.2) + foreman (0.88.1) forwardable (1.3.3) - get_process_mem (0.2.7) - ffi (~> 1.0) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) gettext (3.4.9) erubi locale (>= 2.0.5) @@ -257,82 +232,112 @@ GEM text (>= 1.3.0) globalid (1.2.1) activesupport (>= 6.1) - google-apis-core (0.13.0) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) googleauth (~> 1.9) - httpclient (>= 2.8.1, < 3.a) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.18.0) - google-apis-core (>= 0.12.0, < 2.a) - google-apis-storage_v1 (0.33.0) - google-apis-core (>= 0.12.0, < 2.a) - google-cloud-core (1.6.1) + google-apis-iamcredentials_v1 (0.24.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.54.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (2.1.1) + google-cloud-env (2.3.1) + base64 (~> 0.2) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.3.1) - google-cloud-storage (1.48.1) + google-cloud-errors (1.5.0) + google-cloud-storage (1.56.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (~> 0.13) google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (~> 0.33) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.10.0) + google-logging-utils (0.2.0) + googleauth (1.14.0) faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) + 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) - heapy (0.2.0) - thor - high_voltage (3.1.2) - highline (3.0.1) - httpclient (2.8.3) - i18n (1.14.1) + high_voltage (4.0.0) + highline (3.1.2) + reline + httpclient (2.9.0) + mutex_m + i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.13) + i18n-tasks (1.0.15) activesupport (>= 4.0.2) ast (>= 2.1.0) - better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n parser (>= 3.2.2.1) rails-i18n rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) - io-console (0.7.2) - irb (1.11.2) - rdoc + importmap-rails (2.1.0) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + iniparse (1.5.0) + invisible_captcha (2.3.0) + rails (>= 5.2) + io-console (0.8.0) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.11.5) + jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - json (2.7.1) - jwt (2.7.1) - kramdown (2.4.0) - rexml - language_server-protocol (3.17.0.3) - listen (3.8.0) + jquery-rails (4.6.0) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) + json (2.12.2) + jwt (2.10.2) + base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + kramdown (2.5.1) + rexml (>= 3.3.9) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - locale (2.1.3) - lockbox (1.3.3) + locale (2.1.4) + lockbox (2.0.1) + logger (1.7.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.22.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -343,174 +348,198 @@ GEM mail_form (1.10.1) actionmailer (>= 5.2) activemodel (>= 5.2) - marcel (1.0.2) - matrix (0.4.2) - memory_profiler (1.0.1) - method_source (1.0.0) - mini_histogram (0.3.1) + mailbin (1.0.0) + importmap-rails + rails (>= 7.1.0) + turbo-rails + marcel (1.0.4) + matrix (0.4.3) + method_source (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.22.2) - minitest-rails (7.1.0) + mini_portile2 (2.8.9) + minitest (5.25.5) + minitest-rails (7.2.0) minitest (~> 5.20) - railties (~> 7.1.0) - minitest-reporters (1.6.1) + railties (>= 7.2.0, < 8.0.0) + minitest-reporters (1.7.1) ansi builder minitest (>= 5.0) ruby-progressbar - msgpack (1.7.2) + mission_control-jobs (1.0.2) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails + msgpack (1.8.0) multi_json (1.15.0) - multipart-post (2.4.0) - mutex_m (0.2.0) + multipart-post (2.4.1) + mutex_m (0.3.0) mysql2 (0.5.6) - net-http-persistent (4.0.2) - connection_pool (~> 2.2) - net-imap (0.4.10) + net-http-persistent (4.0.6) + connection_pool (~> 2.2, >= 2.2.4) + net-imap (0.5.9) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0.1) + net-smtp (0.5.1) net-protocol - nio4r (2.7.0) - nokogiri (1.16.2) + nio4r (2.7.4) + nokogiri (1.18.8) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.2-aarch64-linux) - racc (~> 1.4) - nokogiri (1.16.2-arm-linux) - racc (~> 1.4) - nokogiri (1.16.2-x86-linux) - racc (~> 1.4) - nokogiri (1.16.2-x86_64-linux) - racc (~> 1.4) - oj (3.16.3) + oj (3.16.11) bigdecimal (>= 3.0) + ostruct (>= 0.2) orm_adapter (0.5.0) os (1.1.4) - parallel (1.24.0) - parser (3.3.0.5) + ostruct (0.6.2) + overcommit (0.68.0) + childprocess (>= 0.6.3, < 6) + iniparse (~> 1.4) + rexml (>= 3.3.9) + overmind (2.5.1) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) racc - pg (1.5.4) + pg (1.5.9) popper_js (2.11.8) - prime (0.1.2) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prime (0.1.4) forwardable singleton - pry (0.14.2) + prism (1.4.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - pry-rails (0.3.9) - pry (>= 0.10.4) - psych (5.1.2) + pry-rails (0.3.11) + pry (>= 0.13.0) + psych (5.2.6) + date stringio - public_suffix (5.0.4) - puma (6.4.2) + public_suffix (6.0.2) + puma (6.6.0) nio4r (~> 2.0) - racc (1.7.3) - rack (3.0.9) + raabro (1.4.0) + racc (1.8.1) + rack (3.1.16) + rack-accept (0.4.5) + rack (>= 0.4) rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-cors (2.0.1) - rack (>= 2.0.0) - rack-session (2.0.0) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-session (2.1.1) + base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rack-throttle (0.7.0) - bundler (>= 1.0.0) - rack (>= 1.0.0) - rack-timeout (0.6.3) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) - rails (7.1.3) - actioncable (= 7.1.3) - actionmailbox (= 7.1.3) - actionmailer (= 7.1.3) - actionpack (= 7.1.3) - actiontext (= 7.1.3) - actionview (= 7.1.3) - activejob (= 7.1.3) - activemodel (= 7.1.3) - activerecord (= 7.1.3) - activestorage (= 7.1.3) - activesupport (= 7.1.3) + rails (7.2.2.1) + actioncable (= 7.2.2.1) + actionmailbox (= 7.2.2.1) + actionmailer (= 7.2.2.1) + actionpack (= 7.2.2.1) + actiontext (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activemodel (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) bundler (>= 1.15.0) - railties (= 7.1.3) - rails-dom-testing (2.2.0) + railties (= 7.2.2.1) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - rails-i18n (7.0.8) + 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) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.3) - actionpack (= 7.1.3) - activesupport (= 7.1.3) - irb + railties (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.1.0) + rake (13.3.0) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (6.6.2) + rdoc (6.14.2) + erb psych (>= 4.0.0) - regexp_parser (2.9.0) - reline (0.4.2) + 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) - request_store (1.6.0) + request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) retriable (3.1.2) - rexml (3.2.6) - rollbar (3.5.1) - route_translator (14.1.1) - actionpack (>= 6.1, < 7.2) - activesupport (>= 6.1, < 7.2) - rubocop (1.60.2) + rexml (3.4.1) + rollbar (3.6.2) + rqrcode (3.1.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.0.0) + rubocop (1.75.8) json (~> 2.3) - language_server-protocol (>= 3.17.0) + 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 (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) - rubocop-performance (1.20.2) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.23.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.45.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - ruby-debug-ide (0.7.3) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-debug-ide (0.7.5) rake (>= 0.8.1) ruby-progressbar (1.13.0) - ruby-statistics (3.0.2) ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) sass-rails (6.0.0) sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) @@ -521,53 +550,81 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (4.10.0) + securerandom (0.4.1) + selectize-rails (0.12.6) + selenium-webdriver (4.34.0) + base64 (~> 0.2) + logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - signet (0.18.0) + signet (0.20.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - simple_token_authentication (1.18.1) - actionmailer (>= 3.2.6, < 8) - actionpack (>= 3.2.6, < 8) - devise (>= 3.2, < 6) - singleton (0.2.0) + singleton (0.3.0) smart_properties (1.17.0) - sprockets (4.2.1) + solid_queue (1.1.5) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (~> 1.3.1) + sprockets (4.2.2) concurrent-ruby (~> 1.0) + logger rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (1.7.2) + sqlite3 (2.7.2) mini_portile2 (~> 2.8.0) - stackprof (0.2.26) - stimulus-rails (1.3.3) + standard (1.50.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.75.5) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) + standardrb (1.0.1) + standard + stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - terser (1.2.0) + stringio (3.1.7) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + terser (1.2.6) execjs (>= 0.3.0, < 3) text (1.3.1) - thor (1.3.0) - tilt (2.3.0) - timeout (0.4.1) + thor (1.3.2) + tilt (2.6.1) + timeout (0.4.3) trailblazer-option (0.1.2) - translation (1.38) + translation (1.41) gettext (~> 3.2, >= 3.2.5, <= 3.4.9) - turbo-rails (1.5.0) - actionpack (>= 6.0.0) - activejob (>= 6.0.0) - railties (>= 6.0.0) + turbo-rails (2.0.16) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + turnout2024 (3.0.1) + i18n (>= 0.7, < 2) + rack (>= 3, < 4) + rack-accept (~> 0.4) + tilt (>= 2.3, < 3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (2.5.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + useragent (0.16.11) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -575,88 +632,83 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webdrivers (5.3.1) - nokogiri (~> 1.6) - rubyzip (>= 1.3.0) - selenium-webdriver (~> 4.0, < 4.11) - webrick (1.8.1) - websocket (1.2.10) - websocket-driver (0.7.6) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - will_paginate (4.0.0) - will_paginate-bootstrap-style (0.3.0) - will_paginate (~> 4.0, >= 4.0.0) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.13) + zeitwerk (2.7.3) PLATFORMS - linux ruby DEPENDENCIES + administrate (~> 0.20.1) apipie-rails aws-sdk-s3 azure-storage-blob (~> 2.0) - bootsnap (>= 1.4.4) - bootstrap (= 5.2.3) + bootsnap + bootstrap + brakeman capybara (>= 3.37.1, < 4.0) config + cssbundling-rails (~> 1.4) debase (>= 0.2.5.beta2) debug - derailed_benchmarks devise (>= 4.9.0) devise-i18n + erb_lint (~> 0.9.0) foreman - google-cloud-storage (~> 1.48) + google-cloud-storage (~> 1.56) high_voltage - i18n-tasks (~> 1.0.13) + i18n-tasks (~> 1.0.15) + invisible_captcha (~> 2.3) jbuilder - json (~> 2.7) + json (~> 2.12) + kaminari (~> 1.2) kramdown listen lockbox lograge mail_form (>= 1.9.0) + mailbin minitest minitest-rails (>= 6.1.0) minitest-reporters + mission_control-jobs (~> 1.0.2) mysql2 oj + overcommit + overmind (~> 2.5) pg pry-rails puma rack-attack rack-cors - rack-throttle (= 0.7.0) - rack-timeout - rails (~> 7.1.3) - rails-i18n (~> 7.0.8) + rails (~> 7.2.2) + rails-i18n (~> 7.0.10) rollbar - route_translator (>= 13.0.0) - rubocop - rubocop-performance - rubocop-rails + rqrcode (~> 3.1) + rubocop-rails-omakase ruby-debug-ide sass-rails (~> 6.0, >= 6.0.0) - selenium-webdriver - simple_token_authentication + selenium-webdriver (>= 4.20.1) + solid_queue (~> 1.1) sqlite3 - stackprof + standardrb (~> 1.0) stimulus-rails terser (~> 1.2) translation turbo-rails + turnout2024 tzinfo-data version! - web-console (>= 4.2.0) - webdrivers (~> 5.3) - will_paginate (~> 4.0.0) - will_paginate-bootstrap-style + web-console RUBY VERSION - ruby 3.1.4p223 + ruby 3.4.3p32 BUNDLED WITH - 2.4.19 + 2.5.17 diff --git a/Procfile b/Procfile index 865eaaa7b76..a47803197d0 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,4 @@ -release: bundle exec rails db:migrate web: bundle exec puma -C config/puma.rb +worker: bundle exec rake solid_queue:start +release: bundle exec rails db:migrate console: bundle exec rails console diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 00000000000..238e22e8d83 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,4 @@ +web: bin/rails server -p 5100 +js: yarn build --reload +css: yarn watch:css +worker: bundle exec rake solid_queue:start diff --git a/README.md b/README.md index 3bc297ef142..cbf78887930 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ [![Password Pusher Front Page](https://pwpush.fra1.cdn.digitaloceanspaces.com/branding/logos/horizontal-logo-small.png)](https://pwpush.com/) -__Simple & Secure Password Sharing with Auto-Expiration of Shared Items__ +__Share sensitive information securely (files too!) with self-deleting links & full audit logs.__ [![](https://badgen.net/twitter/follow/pwpush)](https://twitter.com/pwpush) ![](https://badgen.net/github/stars/pglombardo/PasswordPusher) [![](https://badgen.net/uptime-robot/month/m789048867-17b5770ccd78208645662f1f)](https://stats.uptimerobot.com/6xJjNtPr93) -[![](https://badgen.net/docker/pulls/pglombardo/pwpush)](https://hub.docker.com/repositories) +[![](https://badgen.net/docker/pulls/pglombardo/pwpush-ephemeral)](https://hub.docker.com/repositories) [![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/pglombardo/PasswordPusher/ruby-tests.yml)](https://github.com/pglombardo/PasswordPusher/actions/workflows/ruby-tests.yml) [![Dependencies Status](https://img.shields.io/badge/dependencies-up%20to%20date-brightgreen.svg)](https://github.com/pglombardo/pwpush-cli/pulls?utf8=%E2%9C%93&q=is%3Apr%20author%3Aapp%2Fdependabot) @@ -20,33 +20,34 @@ __Simple & Secure Password Sharing with Auto-Expiration of Shared Items__ Give your users the tools to be secure by default. -Password Pusher is an opensource application to communicate passwords over the web. Links to passwords expire after a certain number of views and/or time has passed. +Password Pusher is an open source application to communicate sensitive information over the web. Secret links expire after a certain number of views and/or time has passed. Hosted at [pwpush.com](https://pwpush.com) but you can also easily run your own private instance with just a few steps. * __Easy-to-install:__ Host your own via Docker, a cloud service or just use [pwpush.com](https://pwpush.com) -* __Opensource:__ No blackbox code. Only trusted, tested and reviewed opensource code. -* __Versatile:__ Push passwords, text, files or URLs that autoexpire and self delete. +* __Open Source:__ No blackbox code. Only trusted, tested and reviewed open source code. +* __Versatile:__ Push passwords, text, files or URLs that auto-expire and self delete. * __Audit logging:__ Track and control what you've shared and see who has viewed it. * __Encrypted storage:__ All sensitive data is stored encrypted and deleted entirely once expired. * __Host your own:__ Database backed or ephemeral, easily run your own instance isolated from the world. -* __JSON API:__ Raw JSON API available for 3rd party tools or command line via `curl` or `wget`. -* __Command line interface:__ Automate your password distribution with CLI tools or custom scripts. +* __Admin Dashboard:__ Manage your self-hosted instance with a built in admin dashboard. * __Logins__: Invite your colleagues and track what is pushed and who retrieved it. -* __Internationalized:__ 29 language translations are bundled in. Easily selectable via UI or URL -* __Themes:__ [26 themes](./Themes.md) bundled in courtesy of Bootswatch. Select with a simple environment variable. * __Unbranded delivery page:__ No logos, superfluous text or unrelated links to confuse end users. +* __Internationalized:__ 29 language translations are bundled in. Easily selectable via UI or URL +* __JSON API:__ Raw JSON API available for 3rd party tools or command line via `curl` or `wget`. +* __Command line interface:__ Automate your password distribution with CLI tools or custom scripts. +* __Themes:__ [26 themes](https://docs.pwpush.com/docs/themes/) bundled in courtesy of [Bootswatch](https://github.com/thomaspark/bootswatch). Select with a simple environment variable. * __Customizable:__ Change text and default options via environment variables. * __Light & dark themes:__ Via CSS @media integration, the default site theme follows your local preferences. -* __Rebrandable:__ Customize the site name, tagline and logo to fit your environment. +* __Re-Brandable:__ Completely white label: customize the theme, site name, tagline and logo to fit your environment. * __Custom CSS:__ Bundle in your own custom CSS to add your own design. -* __10 Years Old:__ Password Pusher has securely delivered millions and millions of passwords in its 10 year history. +* __>10 Years Old:__ Password Pusher has securely delivered millions and millions of passwords in its 14 year history. * __Actively Maintained:__ I happily work for the good karma of the great IT/Security community. -* __Honest Software:__ Opensource written and maintained by [me](https://github.com/pglombardo) with the help of some great contributors. No organizations, corporations or evil agendas. +* __Honest Software:__ Open source written and maintained by [me](https://github.com/pglombardo) with the help of some great contributors. No organizations, corporations or evil agendas. 💌 --> Sign up for [the newsletter](https://buttondown.email/pwpush?tag=github) to get updates on big releases, security issues, new features, integrations, tips and more. -Password Pusher is also [on Twitter](https://twitter.com/pwpush), [Gettr](https://gettr.com/user/pwpush) and [on Facebook](https://www.facebook.com/pwpush) +Follow Password Pusher updates on [X](https://x.com/pwpush), [Reddit](https://www.reddit.com/r/pwpush), [Gettr](https://gettr.com/user/pwpush) and [Facebook](https://www.facebook.com/pwpush). ----- @@ -57,230 +58,49 @@ Password Pusher is also [on Twitter](https://twitter.com/pwpush), [Gettr](https: [![](./app/assets/images/features/dark-theme-thumb.png)](./app/assets/images/features/dark-theme.gif) [![](./app/assets/images/features/preliminary-step-thumb.png)](./app/assets/images/features/preliminary-step.gif) +# Editions -# ⚡️ Quickstart - -→ Go to [pwpush.com](https://pwpush.com) and try it out. - -_or_ - -→ Run your own instance with one command: `docker run -d -p "5100:5100" pglombardo/pwpush:release` then go to http://localhost:5100 - -_or_ - -→ Use one of the [3rd party tools](#3rd-party-tools) that interface with Password Pusher. - -# 💾 Run Your Own Instance - - 🎉 🎉 🎉 - - __We've recently introduced a single universal container. Migration for existing users is easy - please refer to [the documentation here](https://github.com/pglombardo/PasswordPusher/wiki/How-to-migrate-to-the-Universal-Container).__ - - 🎉 🎉 🎉 - -_Note: Password Pusher can be largely configured by environment variables so after you pick your deployment method below, make sure to read [the configuration page](Configuration.md). Take particular attention in setting your own custom encryption key which isn't required but provides the best security for your instance._ - -## On Docker - -Docker images of Password Pusher are available on [Docker hub](https://hub.docker.com/u/pglombardo). - -**➜ ephemeral** -_Temporary database that is wiped on container restart._ - - docker run -d -p "5100:5100" pglombardo/pwpush:release - -[Learn more](https://github.com/pglombardo/PasswordPusher/tree/master/containers/docker#ephemeral) - -**➜ using an External Postgres Database** -_Postgres database backed instance._ - - docker run -d -p "5100:5100" pglombardo/pwpush:release -e DATABASE_URL=postgres://pwpush_user:pwpush_passwd@postgres:5432/pwpush_db - -[Learn more](https://github.com/pglombardo/PasswordPusher/tree/master/containers/docker#postgres) - -**➜ using an External MariaDB (MySQL) Database** -_Mariadb database backed instance._ - - docker run -d -p "5100:5100" pglombardo/pwpush:release -e DATABASE_URL=mysql2://pwpush_user:pwpush_passwd@mysql:3306/pwpush_db - -[Learn more](https://github.com/pglombardo/PasswordPusher/tree/master/containers/docker#mysql) - -_Note: The `latest` Docker container tag builds nightly off of the latest code changes and can occasionally be unstable. Always use the ['release' or version'd tags](https://hub.docker.com/r/pglombardo/pwpush/tags?page=1&ordering=last_updated) if you prefer more stability in releases._ - -**See Also:** [Guide to DATABASE_URL](https://github.com/pglombardo/PasswordPusher/wiki/Guide-to-DATABASE_URL) - -## With Docker Compose - -**➜ One-liner Password Pusher with a Postgres Database** - - curl -s -o docker-compose.yml https://raw.githubusercontent.com/pglombardo/PasswordPusher/master/containers/docker/docker-compose-postgres.yml && docker compose up -d - -**➜ One-liner Password Pusher with a MariaDB (MySQL) Database** - - curl -s -o docker-compose.yml https://raw.githubusercontent.com/pglombardo/PasswordPusher/master/containers/docker/docker-compose-mariadb.yml && docker compose up -d - -## On Kubernetes - -Instructions and explanation of a Kubernetes setup [can be found -here](https://github.com/pglombardo/PasswordPusher/tree/master/containers/kubernetes). - -## On Kubernetes with Helm - -A basic helm chart with instructions [can be found here](containers/helm/). - -## On Microsoft Azure - -_There used to be a 3rd party blog post with instructions but it's been deleted. If anyone has instructions they would like to contribute, it would be greatly appreciated._ - -See [issue #277](https://github.com/pglombardo/PasswordPusher/issues/277) - -## On Heroku - -One click deploy to [Heroku Cloud](https://www.heroku.com) without having to set up servers. - -[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/pglombardo/PasswordPusher) - -_This option will deploy a production Password Pusher instance backed by a postgres database to Heroku. Heroku used to offer free dynos but that is [no longer the case](https://blog.heroku.com/next-chapter) from November 28, 2022. Hosting charges will be incurred._ - -## On PikaPods - -One click deploy to [PikaPods](https://www.pikapods.com/) from $1/month. Start free with $5 welcome credit. - -[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=pwpush) - -## With Nginx - -See the prebuilt [Docker Compose example here](https://github.com/pglombardo/PasswordPusher/tree/master/containers/examples/pwpush-and-nginx). - -## From Source - -I generally don't suggest building this application from source code for casual use. The is due to the complexities in the toolset across platforms. Running from source code is best when you plan to develop the application. +If you are considering to self-host the OSS edition, you can try it out immediately at [https://oss.pwpush.com](https://oss.pwpush.com). -For quick and easy, use the Docker containers instead. +In 2024, I introduced a set of **Pro features** exclusively on [pwpush.com](https://pwpush.com) to better support the project. -But if you're resolute & brave, continue on! +These Pro features are periodically migrated to the OSS edition. You can read more about how this works [here](https://docs.pwpush.com/docs/editions/). -### Dependencies +To see the differences between pwpush.com and the OSS edition take a look at the [Feature Matrix](https://pwpush.com/features#matrix). -* Ruby 3.0 or greater -* Recent Node.js stable & Yarn -* Compiler tools: gcc g++ make -* Other: git +# ⚡️ Quick Start -### SQLite3 backend +→ Run your own instance with `docker run -d -p "5100:5100" pglombardo/pwpush:stable` or a [production ready setup with a database & SSL/TLS](https://github.com/pglombardo/PasswordPusher/tree/master/containers/docker/all-in-one). -* Make sure to install sqlite3 development libraries: `apt install libsqlite3-dev sqlite3` - -```sh -git clone git@github.com:pglombardo/PasswordPusher.git -cd PasswordPusher -gem install bundler - -bundle config set --local deployment 'true' -bundle install --without development production test -./bin/rails assets:precompile -./bin/rails db:setup -./bin/rails server -``` - -Then view the site @ [http://localhost:5100/](http://localhost:5100/). - -### Postgres, MySQL or Mariadb backend - -* Make sure to install related database driver development libraries: e.g. postgres-dev or libmariadb-dev - -```sh -git clone git@github.com:pglombardo/PasswordPusher.git -cd PasswordPusher -gem install bundler - -export RAILS_ENV=production - -# Update the following line to point to your Postgres (or MySQL/Mariadb) instance -DATABASE_URL=postgresql://passwordpusher_user:passwordpusher_passwd@postgres:5432/passwordpusher_db - -bundle install --without development test -./bin/rails assets:precompile -./bin/rails db:setup -./bin/rails server --environment=production -``` - -Then view the site @ [http://localhost:5100/](http://localhost:5100/). - - -# 🔨 3rd Party Tools - -## Command Line Utilities - -* The almost official [pwpush-cli](https://github.com/pglombardo/pwpush-cli) (in pre-beta): CLI for Password Pusher with authentication support - -* [thekamilpro/kppwpush](https://github.com/thekamilpro/kppwpush): A PowerShell Module available in the [PowerShell Gallery](https://www.powershellgallery.com/packages/KpPwpush/0.0.1). See the livestream of its creation on [The Kamil Pro's channel](https://www.youtube.com/watch?v=f8_PZOx_KBY&feature=youtu.be). - -* [pgarm/pwposh](https://github.com/pgarm/pwposh): a PowerShell module available in the [PowerShell Gallery](https://www.powershellgallery.com/packages/PwPoSh/) - -* [lnfnunes/pwpush-cli](https://github.com/lnfnunes/pwpush-cli): a Node.js based CLI - -* [abkierstein/pwpush](https://github.com/abkierstein/pwpush): a Python based CLI - -## GUIs - -* [Tachaeon/PWPush-Generator](https://github.com/Tachaeon/PWPush-Generator): A powershell GUI frontend for pwpush.com - -## Libraries & APIs - -* [oyale/PwPush-PHP](https://github.com/oyale/PwPush-PHP): a PHP library wrapper to easily push passwords to any Password Pusher instance - -## Android Apps - -* [Pushie](https://play.google.com/store/apps/details?id=com.chesire.pushie) by [chesire](https://github.com/chesire) - -## Application Integrations - -* [Slack: How to Add a Custom Slash Command](https://github.com/pglombardo/PasswordPusher/wiki/PasswordPusher-&-Slack:-Custom-Slash-Command) - -* [Unraid Application](https://forums.unraid.net/topic/104128-support-passwordpusher-pwpush-corneliousjd-repo/) - -* [Alfred Workflow](http://www.packal.org/workflow/passwordpusher) for Mac users - -_See also the [Tools Page on pwpush.com](https://pwpush.com/en/pages/tools)._ - -# 📡 The Password Pusher API - -* [JSON API Documentation](https://pwpush.com/api) -* [Walkthrough & Examples](https://github.com/pglombardo/PasswordPusher/wiki/Password-API) - -# 🇮🇹 Internationalization - -Password Pusher is currently available in **29 languages** with more languages being added often as volunteers apply. +_or_ -From within the application, the language is selectable from a language menu. Out of the box and before any language menu selection is done, the default language for the application is English. +→ Use one of the [3rd party tools](https://docs.pwpush.com/docs/3rd-party-tools/) that interface with Password Pusher. -## Changing the Default Language +# 📚 Documentation -The default language can be changed by setting an environment variable with the appropriate language code: +See the full [Password Pusher documentation here](https://docs.pwpush.com). - PWP__DEFAULT_LOCALE=es +# 🌎 Language Translations -For more details, a list of supported language codes and further explanation, see the bottom of this [configuration file](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml). +For years, [Translation.io](https://translation.io/?utm_source=pwpush) has provided free access to their translation tools for the open-source version of Password Pusher. For this reason we now have **31 language translations built in**! -# 🛟 Help Out +[![](./app/assets/images/partners/translation-io-banner.png)](https://translation.io/?utm_source=pwpush) -[pwpush.com](https://pwpush.com) is hosted on Digital Ocean and is happily paid out of pocket by myself for more than 10 years. +Please say thanks to [Translation.io](https://translation.io/?utm_source=pwpush) by considering them for any translation needs your company (or open source project) might have! -__But you could help out greatly__ by signing up to Digital Ocean with [this link](https://m.do.co/c/f4ea6ef24c13) (and get $200 credit). In return, Password Pusher gets a helpful hosting credit. -**tldr;** Sign up to Digital Ocean [with this link](https://m.do.co/c/f4ea6ef24c13), **get a $200 credit for free** and help Password Pusher out. +# 📼 Credits -[![DigitalOcean Referral Badge](https://web-platforms.sfo2.cdn.digitaloceanspaces.com/WWW/Badge%201.svg)](https://www.digitalocean.com/?refcode=f4ea6ef24c13&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) +## Security Researchers -# 📼 Credits +* Kullai Metikala | [Github](https://github.com/kullaisec) | [LinkedIn](https://www.linkedin.com/in/kullai-metikala-8378b122a/) +* [Positive Technologies](https://global.ptsecurity.com) +* Igniter | [Github](https://github.com/igniter07) ## Translators Thanks to our great translators! -If you would like to volunteer and assist in translating, see [this page](https://pwpush.com/en/pages/translate). - | Name | Language | | |---|---|---| | [Oyale](https://github.com/oyale) | [Catalan](https://pwpush.com/ca) | | @@ -295,12 +115,12 @@ If you would like to volunteer and assist in translating, see [this page](https: | [Fabrício Rodrigues](https://www.linkedin.com/in/ifabriciorodrigues/)| [Portuguese](https://pwpush.com/pt-br/p/novo) | | | [Ivan Freitas](https://github.com/IvanMFreitas)| [Portuguese](https://pwpush.com/pt-br/p/novo) | | | Sara Faria| [Portuguese](https://pwpush.com/pt-br/p/novo) | | -| [Oyale](https://github.com/oyale) |[Spanish](https://pwpush.com/pt-br/p/novo) | | +| [Oyale](https://github.com/oyale) |[Spanish](https://pwpush.com/es) | | | johan323 |[Swedish](https://pwpush.com/sv/p/ny) | | | Fredrik Arvas|[Swedish](https://pwpush.com/sv/p/ny) | | | Pedro Marques | [European Portuguese](https://pwpush.com/pt-pt/p/novo) | | -Also thanks to [translation.io](https://translation.io) for their great service in managing translations. It's also generously free for opensource projects. +Also thanks to [translation.io](https://translation.io) for their great service in managing translations. It's also generously free for open source projects. ## Containers @@ -324,6 +144,43 @@ Thanks to: ...and many more. See the [Contributors page](https://github.com/pglombardo/PasswordPusher/graphs/contributors) for more details. +# 🎁 Donations + +**Donations are in no way required of any Password Pusher user. The project, at it's core, is and always has been open source and free to use.** + +With that said, if you find Password Pusher useful and would like to support & accelerate it's continued development all donations are _greatly appreciated_. + +| [![Donate](https://pwpush.fra1.cdn.digitaloceanspaces.com/misc/pwpush-donate-stripe-qr-small.png)]() | or | [![Donate](https://img.shields.io/badge/Donate-Stripe-blue.svg)](https://buy.stripe.com/7sI4gCgTT1tr6WY3cd) | +|---|---|---| + + +As an alternative to donations, you can also support the project by signing up for a [paid plan at pwpush.com](https://pwpush.com/pricing). + +Donations are used to pay for the following: + +* Hosting costs (Digital Ocean, Hatchbox, Brevo Support & Transactional Email, Docker Hub, Uptime Robot) +* Community Support +* On-going Maintenance + * Upgrades + * Testing +* Continued development + * Development tools + * License costs + * Documentation + +**Legal Disclaimer:** Please note that Password Pusher is owned and operated by Apnotic, LLC, a for-profit company owned and operated by [me](https://github.com/pglombardo). While donations are greatly appreciated and help support the project's development, they are not tax deductible as charitable contributions. Donations made to Password Pusher directly support a commercial entity and should be viewed as a voluntary payment to help sustain the service and encourage continued development. + +**See Also:** + +* [What is Apnotic, LLC?](https://docs.pwpush.com/docs/faq/#what-is-apnotic) +* [Trust is a concern. Why should I trust and use Password Pusher?](https://docs.pwpush.com/docs/faq/#trust-is-a-concern--why-should-i-trust-and-use-password-pusher) +* [How does the Pro feature pipeline work?](https://docs.pwpush.com/posts/feature-pipeline/) + +# Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=pglombardo/PasswordPusher&type=Date)](https://www.star-history.com/#pglombardo/PasswordPusher&Date) + + # 🛡 License [![License](https://img.shields.io/github/license/pglombardo/PasswordPusher)](https://github.com/pglombardo/PasswordPusher/blob/master/LICENSE) @@ -335,8 +192,8 @@ This project is licensed under the terms of the `Apache License 2.0` license. Se ```bibtex @misc{PasswordPusher, author = {Peter Giacomo Lombardo}, - title = {An application to securely communicate passwords over the web. Passwords automatically expire after a certain number of views and/or time has passed.}, - year = {2022}, + title = {Securely share sensitive information with automatic expiration & deletion after a set number of views or duration. Track who, what and when with full audit logs.}, + year = {2025}, publisher = {GitHub}, journal = {GitHub repository}, howpublished = {\url{https://github.com/pglombardo/PasswordPusher}} diff --git a/Rakefile b/Rakefile index 444b77182b9..01bf6eca090 100644 --- a/Rakefile +++ b/Rakefile @@ -3,13 +3,13 @@ # 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 File.expand_path('config/application', __dir__) +require File.expand_path("config/application", __dir__) PasswordPusher::Application.load_tasks # Add version gem rake tasks -require 'rake/version_task' +require "rake/version_task" Rake::VersionTask.new do |task| task.with_git_tag = true - task.git_tag_prefix = 'v' + task.git_tag_prefix = "v" end diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..890fa6d4bc2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +This page outlines the general procedure for reporting security vulnerabilities. + +All reports (and the time you spend to discover them) are very much appreciated. + +With the community, I hope to keep Password Pusher up to date with the latest developments and best security practices. + +As always, if you have any questions, feel free to [contact me](https://pwpush.com/feedbacks/new) anytime! + +## Supported Versions + +I encourange the safe reporting of security issues for the latest minor +version of the software. e.g. `v1.47.x` as of November 2024. + +This is a general rule but I would be open to hear any and all security reports that you feel are necessary. + +## Reporting a Vulnerability + +You can use the Github "Report a vulnerability" feature or contact me at security@pwpush.com and I will send you a secure request URL to provide the report. + +_I kindly ask you to not report any potential vulnerabilies publicly as it may put existing users at risk. Your help in this is greatly appreciated!_ diff --git a/Themes.md b/Themes.md index a0d7321f229..cd36277e172 100644 --- a/Themes.md +++ b/Themes.md @@ -1,121 +1 @@ -# Themes - -This page serves as a gallery of the themes available in Password Pusher. To select a theme for your instance, simply set the `PWP__THEME` environment variable or update the corresponding value in your [config/settings.yml](https://github.com/pglombardo/PasswordPusher/blob/master/config/settings.yml) file. - -For more information on themes, see [the Themes section in the Configuration document](https://github.com/pglombardo/PasswordPusher/blob/master/Configuration.md#themes) which explains themes in their entirety and how to add custom CSS to further customize the application. - -These themes are bundled in Password Pusher but come from the great [Bootswatch](https://bootswatch.com) project and are licensed under the MIT License. - -# Branding - -Also note you can also "brand" the app by changing the logo, tagline and more. See the [Rebranding section in the Configuration document](https://github.com/pglombardo/PasswordPusher/blob/master/Configuration.md#rebranding) for more details. - -# Theme Gallery - -Onto the themes gallery! - -Set your theme with `PWP__THEME=` - -## Cerulean - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fcerulean-theme-pwpush.com.png) - -## Cosmo - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fcosmo-theme-pwpush.com.png) - -## Cyborg - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fcyborg-theme-pwpush.com.png) - -## Darkly - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fdarkly-theme-pwpush.com.png) - -## Default - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fdefault-theme-pwpush.com.png) - -## Flatly - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fflatly-theme-pwpush.com.png) - -## Journal - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fjournal-theme-pwpush.com.png) - -## Litera - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Flitera-theme-pwpush.com.png) - -## Lumen - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Flumen-theme-pwpush.com.png) - -## Lux - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Flux-theme-pwpush.com.png) - -## Materia - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fmateria-theme-pwpush.com.png) - -## Minty - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fminty-theme-pwpush.com.png) - -## Morph - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fmorph-theme-pwpush.com.png) - -## Pulse - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fpulse-theme-pwpush.com.png) - -## Quartz - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fquartz-theme-pwpush.com.png) - -## Sandstone - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fsandstone-theme-pwpush.com.png) - -## Simplex - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fsimplex-theme-pwpush.com.png) - -## Sketchy - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fsketchy-theme-pwpush.com.png) - -## Slate - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fslate-theme-pwpush.com.png) - -## Solar - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fsolar-theme-pwpush.com.png) - -## Spacelab - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fspacelab-theme-pwpush.com.png) - -## Superhero - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fsuperhero-theme-pwpush.com.png) - -## United - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Funited-theme-pwpush.com.png) - -## Vapor - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fvapor-theme-pwpush.com.png) - -## Yeti - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fyeti-theme-pwpush.com.png) - -## Zephyr - -![](https://pwpush.fra1.cdn.digitaloceanspaces.com/themes%2Fzephyr-theme-pwpush.com.png) +The Themes Gallery has been moved to the [documentation portal](https://docs.pwpush.com/docs/themes-gallery/). diff --git a/VERSION b/VERSION index d2829d87d65..fd3e7da492f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.37.3 +1.58.3 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 7fbd7f025f4..975b324f727 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,3 @@ //= link_tree ../images -//= link_directory ../stylesheets .css -//= link_tree ../javascripts .js +//= link_tree ../builds +//= link application.js diff --git a/app/assets/images/alfredapp.png b/app/assets/images/alfredapp.png deleted file mode 100644 index 90ac3736504..00000000000 Binary files a/app/assets/images/alfredapp.png and /dev/null differ diff --git a/app/assets/images/black_wood.jpg b/app/assets/images/black_wood.jpg deleted file mode 100644 index 64301ae6351..00000000000 Binary files a/app/assets/images/black_wood.jpg and /dev/null differ diff --git a/app/assets/images/button_down.png b/app/assets/images/button_down.png deleted file mode 100644 index b0ab548a151..00000000000 Binary files a/app/assets/images/button_down.png and /dev/null differ diff --git a/app/assets/images/button_over.png b/app/assets/images/button_over.png deleted file mode 100644 index 1329a26103d..00000000000 Binary files a/app/assets/images/button_over.png and /dev/null differ diff --git a/app/assets/images/button_up.png b/app/assets/images/button_up.png deleted file mode 100644 index 62cb9b299de..00000000000 Binary files a/app/assets/images/button_up.png and /dev/null differ diff --git a/app/assets/images/forkme.png b/app/assets/images/forkme.png deleted file mode 100644 index 8fe3a7e1f3a..00000000000 Binary files a/app/assets/images/forkme.png and /dev/null differ diff --git a/app/assets/images/horizontal-logo-small.png b/app/assets/images/horizontal-logo-small.png deleted file mode 100644 index 52eaf30c9db..00000000000 Binary files a/app/assets/images/horizontal-logo-small.png and /dev/null differ diff --git a/app/assets/images/logo-transparent-lg-dark.png b/app/assets/images/logo-transparent-lg-dark.png deleted file mode 100644 index d9e84f73a57..00000000000 Binary files a/app/assets/images/logo-transparent-lg-dark.png and /dev/null differ diff --git a/app/assets/images/naked-logo.png b/app/assets/images/naked-logo.png index 943f6fa65f2..f940e79a16c 100644 Binary files a/app/assets/images/naked-logo.png and b/app/assets/images/naked-logo.png differ diff --git a/app/assets/images/partners/translation-io-banner.png b/app/assets/images/partners/translation-io-banner.png new file mode 100644 index 00000000000..a9bcdfb16c6 Binary files /dev/null and b/app/assets/images/partners/translation-io-banner.png differ diff --git a/app/assets/images/pwpush_logo.png b/app/assets/images/pwpush_logo.png index f5726db0b33..70fc55a8ad0 100644 Binary files a/app/assets/images/pwpush_logo.png and b/app/assets/images/pwpush_logo.png differ diff --git a/app/assets/images/square-logo-small.png b/app/assets/images/square-logo-small.png deleted file mode 100644 index 97963767395..00000000000 Binary files a/app/assets/images/square-logo-small.png and /dev/null differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 542b9afaa7e..0218cf69416 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -37,8 +37,8 @@ var init_adapters = __esm({ "node_modules/@hotwired/turbo-rails/node_modules/@rails/actioncable/src/adapters.js"() { adapters_default = { - logger: self.console, - WebSocket: self.WebSocket + logger: typeof console !== "undefined" ? console : void 0, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : void 0 }; } }); @@ -92,12 +92,11 @@ isRunning() { return this.startedAt && !this.stoppedAt; } - recordPing() { + recordMessage() { this.pingedAt = now(); } recordConnect() { this.reconnectAttempts = 0; - this.recordPing(); delete this.disconnectedAt; logger_default.log("ConnectionMonitor recorded connect"); } @@ -185,7 +184,8 @@ "disconnect_reasons": { "unauthorized": "unauthorized", "invalid_request": "invalid_request", - "server_restart": "server_restart" + "server_restart": "server_restart", + "remote": "remote" }, "default_mount_path": "/cable", "protocols": [ @@ -228,11 +228,12 @@ logger_default.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); return false; } else { - logger_default.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`); + const socketProtocols = [...protocols, ...this.consumer.subprotocols || []]; + logger_default.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); if (this.webSocket) { this.uninstallEventHandlers(); } - this.webSocket = new adapters_default.WebSocket(this.consumer.url, protocols); + this.webSocket = new adapters_default.WebSocket(this.consumer.url, socketProtocols); this.installEventHandlers(); this.monitor.start(); return true; @@ -272,6 +273,9 @@ isActive() { return this.isState("open", "connecting"); } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } // Private isProtocolSupported() { return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; @@ -309,18 +313,27 @@ 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_default.log(`Disconnecting. Reason: ${reason}`); return this.close({ allowReconnect: reconnect }); case message_types.ping: - return this.monitor.recordPing(); + return null; case message_types.confirmation: this.subscriptions.confirmSubscription(identifier); - return this.subscriptions.notify(identifier, "connected"); + 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: @@ -540,6 +553,7 @@ this._url = url; this.subscriptions = new Subscriptions(this); this.connection = new connection_default(this); + this.subprotocols = []; } get url() { return createWebSocketURL(this._url); @@ -558,6 +572,9 @@ return this.connection.open(); } } + addSubProtocol(subprotocol) { + this.subprotocols = [...this.subprotocols, subprotocol]; + } }; } }); @@ -629,13 +646,11 @@ let char = alpha[random(alpha.length)]; return titlecased && !index ? char.toUpperCase() : char; }); - if (hasNumbers) - syllable += random(10); + if (hasNumbers) syllable += random(10); return i && separators ? separators[random(separators.length)] + syllable : syllable; }); var produce = (number, callback) => { - for (var i = 0, result = ""; i < number; i++) - result += callback(i); + for (var i = 0, result = ""; i < number; i++) result += callback(i); return result; }; var buffer = []; @@ -652,41 +667,58 @@ }); // node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js - (function() { - if (window.Reflect === void 0 || window.customElements === void 0 || window.customElements.polyfillWrapFlushCallback) { - return; - } - const BuiltInHTMLElement = HTMLElement; - const wrapperForTheName = { - HTMLElement: function HTMLElement2() { - return Reflect.construct(BuiltInHTMLElement, [], this.constructor); - } - }; - window.HTMLElement = wrapperForTheName["HTMLElement"]; - HTMLElement.prototype = BuiltInHTMLElement.prototype; - HTMLElement.prototype.constructor = HTMLElement; - Object.setPrototypeOf(HTMLElement, BuiltInHTMLElement); - })(); + var turbo_es2017_esm_exports = {}; + __export(turbo_es2017_esm_exports, { + FetchEnctype: () => FetchEnctype, + FetchMethod: () => FetchMethod, + FetchRequest: () => FetchRequest, + FetchResponse: () => FetchResponse, + FrameElement: () => FrameElement, + FrameLoadingStyle: () => FrameLoadingStyle, + FrameRenderer: () => FrameRenderer, + PageRenderer: () => PageRenderer, + PageSnapshot: () => PageSnapshot, + StreamActions: () => StreamActions, + StreamElement: () => StreamElement, + StreamSourceElement: () => StreamSourceElement, + cache: () => cache, + clearCache: () => clearCache, + config: () => config, + connectStreamSource: () => connectStreamSource, + disconnectStreamSource: () => disconnectStreamSource, + fetch: () => fetchWithTurboHeaders, + fetchEnctypeFromString: () => fetchEnctypeFromString, + fetchMethodFromString: () => fetchMethodFromString, + isSafe: () => isSafe, + navigator: () => navigator$1, + registerAdapter: () => registerAdapter, + renderStreamMessage: () => renderStreamMessage, + session: () => session, + setConfirmMethod: () => setConfirmMethod, + setFormMode: () => setFormMode, + setProgressBarDelay: () => setProgressBarDelay, + start: () => start, + visit: () => visit + }); (function(prototype) { - if (typeof prototype.requestSubmit == "function") - return; - prototype.requestSubmit = function(submitter) { - if (submitter) { - validateSubmitter(submitter, this); - submitter.click(); + if (typeof prototype.requestSubmit == "function") return; + prototype.requestSubmit = function(submitter2) { + if (submitter2) { + validateSubmitter(submitter2, this); + submitter2.click(); } else { - submitter = document.createElement("input"); - submitter.type = "submit"; - submitter.hidden = true; - this.appendChild(submitter); - submitter.click(); - this.removeChild(submitter); + submitter2 = document.createElement("input"); + submitter2.type = "submit"; + submitter2.hidden = true; + this.appendChild(submitter2); + submitter2.click(); + this.removeChild(submitter2); } }; - function validateSubmitter(submitter, form) { - submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); - submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button"); - submitter.form == form || raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); + function validateSubmitter(submitter2, form) { + submitter2 instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); + submitter2.type == "submit" || raise(TypeError, "The specified element is not a submit button"); + submitter2.form == form || raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); } function raise(errorConstructor, message, name) { throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name); @@ -696,22 +728,24 @@ function findSubmitterFromClickTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; const candidate = element ? element.closest("input, button") : null; - return (candidate === null || candidate === void 0 ? void 0 : candidate.type) == "submit" ? candidate : null; + return candidate?.type == "submit" ? candidate : null; } function clickCaptured(event) { - const submitter = findSubmitterFromClickTarget(event.target); - if (submitter && submitter.form) { - submittersByForm.set(submitter.form, submitter); + const submitter2 = findSubmitterFromClickTarget(event.target); + if (submitter2 && submitter2.form) { + submittersByForm.set(submitter2.form, submitter2); } } (function() { - if ("submitter" in Event.prototype) - return; + if ("submitter" in Event.prototype) return; let prototype = window.Event.prototype; - if ("SubmitEvent" in window && /Apple Computer/.test(navigator.vendor)) { - prototype = window.SubmitEvent.prototype; - } else if ("SubmitEvent" in window) { - return; + if ("SubmitEvent" in window) { + const prototypeOfSubmitEvent = window.SubmitEvent.prototype; + if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { + prototype = prototypeOfSubmitEvent; + } else { + return; + } } addEventListener("click", clickCaptured, true); Object.defineProperty(prototype, "submitter", { @@ -722,18 +756,18 @@ } }); })(); - var FrameLoadingStyle; - (function(FrameLoadingStyle2) { - FrameLoadingStyle2["eager"] = "eager"; - FrameLoadingStyle2["lazy"] = "lazy"; - })(FrameLoadingStyle || (FrameLoadingStyle = {})); + var FrameLoadingStyle = { + eager: "eager", + lazy: "lazy" + }; var FrameElement = class _FrameElement extends HTMLElement { + static delegateConstructor = void 0; + loaded = Promise.resolve(); static get observedAttributes() { - return ["disabled", "complete", "loading", "src"]; + return ["disabled", "loading", "src"]; } constructor() { super(); - this.loaded = Promise.resolve(); this.delegate = new _FrameElement.delegateConstructor(this); } connectedCallback() { @@ -748,17 +782,21 @@ attributeChangedCallback(name) { if (name == "loading") { this.delegate.loadingStyleChanged(); - } else if (name == "complete") { - this.delegate.completeChanged(); } else if (name == "src") { this.delegate.sourceURLChanged(); - } else { + } else if (name == "disabled") { this.delegate.disabledChanged(); } } + /** + * Gets the URL to lazily load source HTML from + */ get src() { return this.getAttribute("src"); } + /** + * Sets the URL to lazily load source HTML from + */ set src(value) { if (value) { this.setAttribute("src", value); @@ -766,9 +804,34 @@ this.removeAttribute("src"); } } + /** + * Gets the refresh mode for the frame. + */ + get refresh() { + return this.getAttribute("refresh"); + } + /** + * Sets the refresh mode for the frame. + */ + set refresh(value) { + if (value) { + this.setAttribute("refresh", value); + } else { + this.removeAttribute("refresh"); + } + } + get shouldReloadWithMorph() { + return this.src && this.refresh === "morph"; + } + /** + * Determines if the element is loading + */ get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || ""); } + /** + * Sets the value of if the element is loading + */ set loading(value) { if (value) { this.setAttribute("loading", value); @@ -776,9 +839,19 @@ this.removeAttribute("loading"); } } + /** + * Gets the disabled state of the frame. + * + * If disabled, no requests will be intercepted by the frame. + */ get disabled() { return this.hasAttribute("disabled"); } + /** + * Sets the disabled state of the frame. + * + * If disabled, no requests will be intercepted by the frame. + */ set disabled(value) { if (value) { this.setAttribute("disabled", ""); @@ -786,9 +859,19 @@ this.removeAttribute("disabled"); } } + /** + * Gets the autoscroll state of the frame. + * + * If true, the frame will be scrolled into view automatically on update. + */ get autoscroll() { return this.hasAttribute("autoscroll"); } + /** + * Sets the autoscroll state of the frame. + * + * If true, the frame will be scrolled into view automatically on update. + */ set autoscroll(value) { if (value) { this.setAttribute("autoscroll", ""); @@ -796,15 +879,27 @@ this.removeAttribute("autoscroll"); } } + /** + * Determines if the element has finished loading + */ get complete() { return !this.delegate.isLoading; } + /** + * Gets the active state of the frame. + * + * If inactive, source changes will not be observed. + */ get isActive() { return this.ownerDocument === document && !this.isPreview; } + /** + * Sets the active state of the frame. + * + * If inactive, source changes will not be observed. + */ get isPreview() { - var _a, _b; - return (_b = (_a = this.ownerDocument) === null || _a === void 0 ? void 0 : _a.documentElement) === null || _b === void 0 ? void 0 : _b.hasAttribute("data-turbo-preview"); + return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview"); } }; function frameLoadingStyleFromString(style) { @@ -815,107 +910,74 @@ return FrameLoadingStyle.eager; } } - function expandURL(locatable) { - return new URL(locatable.toString(), document.baseURI); - } - function getAnchor(url) { - let anchorMatch; - if (url.hash) { - return url.hash.slice(1); - } else if (anchorMatch = url.href.match(/#(.*)$/)) { - return anchorMatch[1]; - } - } - function getAction(form, submitter) { - const action = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formaction")) || form.getAttribute("action") || form.action; - return expandURL(action); - } - function getExtension(url) { - return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""; - } - function isHTML(url) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/); - } - function isPrefixedBy(baseURL, url) { - const prefix = getPrefix(url); - return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix); - } - function locationIsVisitable(location2, rootLocation) { - return isPrefixedBy(location2, rootLocation) && isHTML(location2); - } - function getRequestURL(url) { - const anchor = getAnchor(url); - return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href; - } - function toCacheKey(url) { - return getRequestURL(url); - } - function urlsAreEqual(left2, right2) { - return expandURL(left2).href == expandURL(right2).href; - } - function getPathComponents(url) { - return url.pathname.split("/").slice(1); - } - function getLastPathComponent(url) { - return getPathComponents(url).slice(-1)[0]; - } - function getPrefix(url) { - return addTrailingSlash(url.origin + url.pathname); - } - function addTrailingSlash(value) { - return value.endsWith("/") ? value : value + "/"; - } - var FetchResponse = class { - constructor(response) { - this.response = response; - } - get succeeded() { - return this.response.ok; - } - get failed() { - return !this.succeeded; - } - get clientError() { - return this.statusCode >= 400 && this.statusCode <= 499; - } - get serverError() { - return this.statusCode >= 500 && this.statusCode <= 599; - } - get redirected() { - return this.response.redirected; - } - get location() { - return expandURL(this.response.url); - } - get isHTML() { - return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/); - } - get statusCode() { - return this.response.status; - } - get contentType() { - return this.header("Content-Type"); - } - get responseText() { - return this.response.clone().text(); - } - get responseHTML() { - if (this.isHTML) { - return this.response.clone().text(); - } else { - return Promise.resolve(void 0); - } - } - header(name) { - return this.response.headers.get(name); - } + var drive = { + enabled: true, + progressBarDelay: 500, + unvisitableExtensions: /* @__PURE__ */ new Set( + [ + ".7z", + ".aac", + ".apk", + ".avi", + ".bmp", + ".bz2", + ".css", + ".csv", + ".deb", + ".dmg", + ".doc", + ".docx", + ".exe", + ".gif", + ".gz", + ".heic", + ".heif", + ".ico", + ".iso", + ".jpeg", + ".jpg", + ".js", + ".json", + ".m4a", + ".mkv", + ".mov", + ".mp3", + ".mp4", + ".mpeg", + ".mpg", + ".msi", + ".ogg", + ".ogv", + ".pdf", + ".pkg", + ".png", + ".ppt", + ".pptx", + ".rar", + ".rtf", + ".svg", + ".tar", + ".tif", + ".tiff", + ".txt", + ".wav", + ".webm", + ".webp", + ".wma", + ".wmv", + ".xls", + ".xlsx", + ".xml", + ".zip" + ] + ) }; function activateScriptElement(element) { if (element.getAttribute("data-turbo-eval") == "false") { return element; } else { const createdScriptElement = document.createElement("script"); - const cspNonce = getMetaContent("csp-nonce"); + const cspNonce = getCspNonce(); if (cspNonce) { createdScriptElement.nonce = cspNonce; } @@ -949,6 +1011,17 @@ } return event; } + function cancelEvent(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + function nextRepaint() { + if (document.visibilityState === "hidden") { + return nextEventLoopTick(); + } else { + return nextAnimationFrame(); + } + } function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } @@ -987,9 +1060,8 @@ }).join(""); } function getAttribute(attributeName, ...elements) { - for (const value of elements.map((element) => element === null || element === void 0 ? void 0 : element.getAttribute(attributeName))) { - if (typeof value == "string") - return value; + for (const value of elements.map((element) => element?.getAttribute(attributeName))) { + if (typeof value == "string") return value; } return null; } @@ -1047,6 +1119,13 @@ const element = getMetaElement(name); return element && element.content; } + function getCspNonce() { + const element = getMetaElement("csp-nonce"); + if (element) { + const { nonce, content } = element; + return nonce == "" ? content : nonce; + } + } function setMetaContent(name, content) { let element = getMetaElement(name); if (!element) { @@ -1058,133 +1137,370 @@ return element; } function findClosestRecursively(element, selector) { - var _a; if (element instanceof Element) { - return element.closest(selector) || findClosestRecursively(element.assignedSlot || ((_a = element.getRootNode()) === null || _a === void 0 ? void 0 : _a.host), selector); + return element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector); } } - var FetchMethod; - (function(FetchMethod2) { - FetchMethod2[FetchMethod2["get"] = 0] = "get"; - FetchMethod2[FetchMethod2["post"] = 1] = "post"; - FetchMethod2[FetchMethod2["put"] = 2] = "put"; - FetchMethod2[FetchMethod2["patch"] = 3] = "patch"; - FetchMethod2[FetchMethod2["delete"] = 4] = "delete"; - })(FetchMethod || (FetchMethod = {})); - function fetchMethodFromString(method) { - switch (method.toLowerCase()) { - case "get": - return FetchMethod.get; - case "post": - return FetchMethod.post; - case "put": - return FetchMethod.put; - case "patch": - return FetchMethod.patch; - case "delete": - return FetchMethod.delete; - } + function elementIsFocusable(element) { + const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; + return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"; } - var FetchRequest = class { - constructor(delegate, method, location2, body = new URLSearchParams(), target = null) { - this.abortController = new AbortController(); - this.resolveRequestPromise = (_value) => { - }; - this.delegate = delegate; - this.method = method; - this.headers = this.defaultHeaders; - this.body = body; - this.url = location2; - this.target = target; - } - get location() { - return this.url; - } - get params() { - return this.url.searchParams; - } - get entries() { - return this.body ? Array.from(this.body.entries()) : []; - } - cancel() { - this.abortController.abort(); - } - async perform() { - const { fetchOptions } = this; - this.delegate.prepareRequest(this); - await this.allowRequestToBeIntercepted(fetchOptions); - try { - this.delegate.requestStarted(this); - const response = await fetch(this.url.href, fetchOptions); - return await this.receive(response); - } catch (error2) { - if (error2.name !== "AbortError") { - if (this.willDelegateErrorHandling(error2)) { - this.delegate.requestErrored(this, error2); - } - throw error2; - } - } finally { - this.delegate.requestFinished(this); + function queryAutofocusableElement(elementOrDocumentFragment) { + return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable); + } + async function around(callback, reader) { + const before = reader(); + callback(); + await nextAnimationFrame(); + const after = reader(); + return [before, after]; + } + function doesNotTargetIFrame(name) { + if (name === "_blank") { + return false; + } else if (name) { + for (const element of document.getElementsByName(name)) { + if (element instanceof HTMLIFrameElement) return false; } + return true; + } else { + return true; } - async receive(response) { - const fetchResponse = new FetchResponse(response); - const event = dispatch("turbo:before-fetch-response", { - cancelable: true, - detail: { fetchResponse }, - target: this.target - }); - if (event.defaultPrevented) { - this.delegate.requestPreventedHandlingResponse(this, fetchResponse); - } else if (fetchResponse.succeeded) { - this.delegate.requestSucceededWithResponse(this, fetchResponse); - } else { - this.delegate.requestFailedWithResponse(this, fetchResponse); + } + function findLinkFromClickTarget(target) { + return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])"); + } + function getLocationForLink(link) { + return expandURL(link.getAttribute("href") || ""); + } + function debounce(fn2, delay) { + let timeoutId = null; + return (...args) => { + const callback = () => fn2.apply(this, args); + clearTimeout(timeoutId); + timeoutId = setTimeout(callback, delay); + }; + } + var submitter = { + "aria-disabled": { + beforeSubmit: (submitter2) => { + submitter2.setAttribute("aria-disabled", "true"); + submitter2.addEventListener("click", cancelEvent); + }, + afterSubmit: (submitter2) => { + submitter2.removeAttribute("aria-disabled"); + submitter2.removeEventListener("click", cancelEvent); } - return fetchResponse; - } - get fetchOptions() { - var _a; - return { - method: FetchMethod[this.method].toUpperCase(), - credentials: "same-origin", - headers: this.headers, - redirect: "follow", - body: this.isSafe ? null : this.body, - signal: this.abortSignal, - referrer: (_a = this.delegate.referrer) === null || _a === void 0 ? void 0 : _a.href - }; + }, + "disabled": { + beforeSubmit: (submitter2) => submitter2.disabled = true, + afterSubmit: (submitter2) => submitter2.disabled = false } - get defaultHeaders() { - return { - Accept: "text/html, application/xhtml+xml" - }; + }; + var Config = class { + #submitter = null; + constructor(config2) { + Object.assign(this, config2); } - get isSafe() { - return this.method === FetchMethod.get; + get submitter() { + return this.#submitter; } - get abortSignal() { - return this.abortController.signal; + set submitter(value) { + this.#submitter = submitter[value] || value; } - acceptResponseType(mimeType) { - this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", "); + }; + var forms = new Config({ + mode: "on", + submitter: "disabled" + }); + var config = { + drive, + forms + }; + function expandURL(locatable) { + return new URL(locatable.toString(), document.baseURI); + } + function getAnchor(url) { + let anchorMatch; + if (url.hash) { + return url.hash.slice(1); + } else if (anchorMatch = url.href.match(/#(.*)$/)) { + return anchorMatch[1]; } - async allowRequestToBeIntercepted(fetchOptions) { - const requestInterception = new Promise((resolve) => this.resolveRequestPromise = resolve); - const event = dispatch("turbo:before-fetch-request", { - cancelable: true, - detail: { - fetchOptions, + } + function getAction$1(form, submitter2) { + const action = submitter2?.getAttribute("formaction") || form.getAttribute("action") || form.action; + return expandURL(action); + } + function getExtension(url) { + return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""; + } + function isPrefixedBy(baseURL, url) { + const prefix = getPrefix(url); + return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix); + } + function locationIsVisitable(location2, rootLocation) { + return isPrefixedBy(location2, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location2)); + } + function getRequestURL(url) { + const anchor = getAnchor(url); + return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href; + } + function toCacheKey(url) { + return getRequestURL(url); + } + function urlsAreEqual(left2, right2) { + return expandURL(left2).href == expandURL(right2).href; + } + function getPathComponents(url) { + return url.pathname.split("/").slice(1); + } + function getLastPathComponent(url) { + return getPathComponents(url).slice(-1)[0]; + } + function getPrefix(url) { + return addTrailingSlash(url.origin + url.pathname); + } + function addTrailingSlash(value) { + return value.endsWith("/") ? value : value + "/"; + } + var FetchResponse = class { + constructor(response) { + this.response = response; + } + get succeeded() { + return this.response.ok; + } + get failed() { + return !this.succeeded; + } + get clientError() { + return this.statusCode >= 400 && this.statusCode <= 499; + } + get serverError() { + return this.statusCode >= 500 && this.statusCode <= 599; + } + get redirected() { + return this.response.redirected; + } + get location() { + return expandURL(this.response.url); + } + get isHTML() { + return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/); + } + get statusCode() { + return this.response.status; + } + get contentType() { + return this.header("Content-Type"); + } + get responseText() { + return this.response.clone().text(); + } + get responseHTML() { + if (this.isHTML) { + return this.response.clone().text(); + } else { + return Promise.resolve(void 0); + } + } + header(name) { + return this.response.headers.get(name); + } + }; + var LimitedSet = class extends Set { + constructor(maxSize) { + super(); + this.maxSize = maxSize; + } + add(value) { + if (this.size >= this.maxSize) { + const iterator = this.values(); + const oldestValue = iterator.next().value; + this.delete(oldestValue); + } + super.add(value); + } + }; + var recentRequests = new LimitedSet(20); + var nativeFetch = window.fetch; + function fetchWithTurboHeaders(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}); + const requestUID = uuid(); + recentRequests.add(requestUID); + modifiedHeaders.append("X-Turbo-Request-Id", requestUID); + return nativeFetch(url, { + ...options, + headers: modifiedHeaders + }); + } + function fetchMethodFromString(method) { + switch (method.toLowerCase()) { + case "get": + return FetchMethod.get; + case "post": + return FetchMethod.post; + case "put": + return FetchMethod.put; + case "patch": + return FetchMethod.patch; + case "delete": + return FetchMethod.delete; + } + } + var FetchMethod = { + get: "get", + post: "post", + put: "put", + patch: "patch", + delete: "delete" + }; + function fetchEnctypeFromString(encoding) { + switch (encoding.toLowerCase()) { + case FetchEnctype.multipart: + return FetchEnctype.multipart; + case FetchEnctype.plain: + return FetchEnctype.plain; + default: + return FetchEnctype.urlEncoded; + } + } + var FetchEnctype = { + urlEncoded: "application/x-www-form-urlencoded", + multipart: "multipart/form-data", + plain: "text/plain" + }; + var FetchRequest = class { + abortController = new AbortController(); + #resolveRequestPromise = (_value) => { + }; + constructor(delegate, method, location2, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) { + const [url, body] = buildResourceAndBody(expandURL(location2), method, requestBody, enctype); + this.delegate = delegate; + this.url = url; + this.target = target; + this.fetchOptions = { + credentials: "same-origin", + redirect: "follow", + method: method.toUpperCase(), + headers: { ...this.defaultHeaders }, + body, + signal: this.abortSignal, + referrer: this.delegate.referrer?.href + }; + this.enctype = enctype; + } + get method() { + return this.fetchOptions.method; + } + set method(value) { + const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData(); + const fetchMethod = fetchMethodFromString(value) || FetchMethod.get; + this.url.search = ""; + const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype); + this.url = url; + this.fetchOptions.body = body; + this.fetchOptions.method = fetchMethod.toUpperCase(); + } + get headers() { + return this.fetchOptions.headers; + } + set headers(value) { + this.fetchOptions.headers = value; + } + get body() { + if (this.isSafe) { + return this.url.searchParams; + } else { + return this.fetchOptions.body; + } + } + set body(value) { + this.fetchOptions.body = value; + } + get location() { + return this.url; + } + get params() { + return this.url.searchParams; + } + get entries() { + return this.body ? Array.from(this.body.entries()) : []; + } + cancel() { + this.abortController.abort(); + } + async perform() { + const { fetchOptions } = this; + this.delegate.prepareRequest(this); + const event = await this.#allowRequestToBeIntercepted(fetchOptions); + try { + this.delegate.requestStarted(this); + if (event.detail.fetchRequest) { + this.response = event.detail.fetchRequest.response; + } else { + this.response = fetchWithTurboHeaders(this.url.href, fetchOptions); + } + const response = await this.response; + return await this.receive(response); + } catch (error2) { + if (error2.name !== "AbortError") { + if (this.#willDelegateErrorHandling(error2)) { + this.delegate.requestErrored(this, error2); + } + throw error2; + } + } finally { + this.delegate.requestFinished(this); + } + } + async receive(response) { + const fetchResponse = new FetchResponse(response); + const event = dispatch("turbo:before-fetch-response", { + cancelable: true, + detail: { fetchResponse }, + target: this.target + }); + if (event.defaultPrevented) { + this.delegate.requestPreventedHandlingResponse(this, fetchResponse); + } else if (fetchResponse.succeeded) { + this.delegate.requestSucceededWithResponse(this, fetchResponse); + } else { + this.delegate.requestFailedWithResponse(this, fetchResponse); + } + return fetchResponse; + } + get defaultHeaders() { + return { + Accept: "text/html, application/xhtml+xml" + }; + } + get isSafe() { + return isSafe(this.method); + } + get abortSignal() { + return this.abortController.signal; + } + acceptResponseType(mimeType) { + this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", "); + } + async #allowRequestToBeIntercepted(fetchOptions) { + const requestInterception = new Promise((resolve) => this.#resolveRequestPromise = resolve); + const event = dispatch("turbo:before-fetch-request", { + cancelable: true, + detail: { + fetchOptions, url: this.url, - resume: this.resolveRequestPromise + resume: this.#resolveRequestPromise }, target: this.target }); - if (event.defaultPrevented) - await requestInterception; + this.url = event.detail.url; + if (event.defaultPrevented) await requestInterception; + return event; } - willDelegateErrorHandling(error2) { + #willDelegateErrorHandling(error2) { const event = dispatch("turbo:fetch-request-error", { target: this.target, cancelable: true, @@ -1193,15 +1509,35 @@ return !event.defaultPrevented; } }; + function isSafe(fetchMethod) { + return fetchMethodFromString(fetchMethod) == FetchMethod.get; + } + function buildResourceAndBody(resource, method, requestBody, enctype) { + const searchParams = Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams; + if (isSafe(method)) { + return [mergeIntoURLSearchParams(resource, searchParams), null]; + } else if (enctype == FetchEnctype.urlEncoded) { + return [resource, searchParams]; + } else { + return [resource, requestBody]; + } + } + function entriesExcludingFiles(requestBody) { + const entries = []; + for (const [name, value] of requestBody) { + if (value instanceof File) continue; + else entries.push([name, value]); + } + return entries; + } + function mergeIntoURLSearchParams(url, requestBody) { + const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); + url.search = searchParams.toString(); + return url; + } var AppearanceObserver = class { + started = false; constructor(delegate, element) { - this.started = false; - this.intersect = (entries) => { - const lastEntry = entries.slice(-1)[0]; - if (lastEntry === null || lastEntry === void 0 ? void 0 : lastEntry.isIntersecting) { - this.delegate.elementAppearedInViewport(this.element); - } - }; this.delegate = delegate; this.element = element; this.intersectionObserver = new IntersectionObserver(this.intersect); @@ -1218,8 +1554,15 @@ this.intersectionObserver.unobserve(this.element); } } + intersect = (entries) => { + const lastEntry = entries.slice(-1)[0]; + if (lastEntry?.isIntersecting) { + this.delegate.elementAppearedInViewport(this.element); + } + }; }; var StreamMessage = class { + static contentType = "text/vnd.turbo-stream.html"; static wrap(message) { if (typeof message == "string") { return new this(createDocumentFragment(message)); @@ -1231,7 +1574,6 @@ this.fragment = importStreamElements(fragment); } }; - StreamMessage.contentType = "text/vnd.turbo-stream.html"; function importStreamElements(fragment) { for (const element of fragment.querySelectorAll("turbo-stream")) { const streamElement = document.importNode(element, true); @@ -1242,86 +1584,88 @@ } return fragment; } - var FormSubmissionState; - (function(FormSubmissionState2) { - FormSubmissionState2[FormSubmissionState2["initialized"] = 0] = "initialized"; - FormSubmissionState2[FormSubmissionState2["requesting"] = 1] = "requesting"; - FormSubmissionState2[FormSubmissionState2["waiting"] = 2] = "waiting"; - FormSubmissionState2[FormSubmissionState2["receiving"] = 3] = "receiving"; - FormSubmissionState2[FormSubmissionState2["stopping"] = 4] = "stopping"; - FormSubmissionState2[FormSubmissionState2["stopped"] = 5] = "stopped"; - })(FormSubmissionState || (FormSubmissionState = {})); - var FormEnctype; - (function(FormEnctype2) { - FormEnctype2["urlEncoded"] = "application/x-www-form-urlencoded"; - FormEnctype2["multipart"] = "multipart/form-data"; - FormEnctype2["plain"] = "text/plain"; - })(FormEnctype || (FormEnctype = {})); - function formEnctypeFromString(encoding) { - switch (encoding.toLowerCase()) { - case FormEnctype.multipart: - return FormEnctype.multipart; - case FormEnctype.plain: - return FormEnctype.plain; - default: - return FormEnctype.urlEncoded; + var PREFETCH_DELAY = 100; + var PrefetchCache = class { + #prefetchTimeout = null; + #prefetched = null; + get(url) { + if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { + return this.#prefetched.request; + } } - } + setLater(url, request, ttl) { + this.clear(); + this.#prefetchTimeout = setTimeout(() => { + request.perform(); + this.set(url, request, ttl); + this.#prefetchTimeout = null; + }, PREFETCH_DELAY); + } + set(url, request, ttl) { + this.#prefetched = { url, request, expire: new Date((/* @__PURE__ */ new Date()).getTime() + ttl) }; + } + clear() { + if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); + this.#prefetched = null; + } + }; + var cacheTtl = 10 * 1e3; + var prefetchCache = new PrefetchCache(); + var FormSubmissionState = { + initialized: "initialized", + requesting: "requesting", + waiting: "waiting", + receiving: "receiving", + stopping: "stopping", + stopped: "stopped" + }; var FormSubmission = class _FormSubmission { - static confirmMethod(message, _element, _submitter) { + state = FormSubmissionState.initialized; + static confirmMethod(message) { return Promise.resolve(confirm(message)); } - constructor(delegate, formElement, submitter, mustRedirect = false) { - this.state = FormSubmissionState.initialized; + constructor(delegate, formElement, submitter2, mustRedirect = false) { + const method = getMethod(formElement, submitter2); + const action = getAction(getFormAction(formElement, submitter2), method); + const body = buildFormData(formElement, submitter2); + const enctype = getEnctype(formElement, submitter2); this.delegate = delegate; this.formElement = formElement; - this.submitter = submitter; - this.formData = buildFormData(formElement, submitter); - this.location = expandURL(this.action); - if (this.method == FetchMethod.get) { - mergeFormDataEntries(this.location, [...this.body.entries()]); - } - this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement); + this.submitter = submitter2; + this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype); this.mustRedirect = mustRedirect; } get method() { - var _a; - const method = ((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("formmethod")) || this.formElement.getAttribute("method") || ""; - return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get; + return this.fetchRequest.method; + } + set method(value) { + this.fetchRequest.method = value; } get action() { - var _a; - const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null; - if ((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.hasAttribute("formaction")) { - return this.submitter.getAttribute("formaction") || ""; - } else { - return this.formElement.getAttribute("action") || formElementAction || ""; - } + return this.fetchRequest.url.toString(); + } + set action(value) { + this.fetchRequest.url = expandURL(value); } get body() { - if (this.enctype == FormEnctype.urlEncoded || this.method == FetchMethod.get) { - return new URLSearchParams(this.stringFormData); - } else { - return this.formData; - } + return this.fetchRequest.body; } get enctype() { - var _a; - return formEnctypeFromString(((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("formenctype")) || this.formElement.enctype); + return this.fetchRequest.enctype; } get isSafe() { return this.fetchRequest.isSafe; } - get stringFormData() { - return [...this.formData].reduce((entries, [name, value]) => { - return entries.concat(typeof value == "string" ? [[name, value]] : []); - }, []); + get location() { + return this.fetchRequest.url; } + // The submission process async start() { const { initialized, requesting } = FormSubmissionState; const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); if (typeof confirmationMessage === "string") { - const answer = await _FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter); + const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : _FormSubmission.confirmMethod; + const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); if (!answer) { return; } @@ -1339,6 +1683,7 @@ return true; } } + // Fetch request delegate prepareRequest(request) { if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token"); @@ -1351,10 +1696,10 @@ } } requestStarted(_request) { - var _a; this.state = FormSubmissionState.waiting; - (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.setAttribute("disabled", ""); + if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); this.setSubmitsWith(); + markAsBusy(this.formElement); dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } @@ -1362,12 +1707,16 @@ this.delegate.formSubmissionStarted(this); } requestPreventedHandlingResponse(request, response) { + prefetchCache.clear(); this.result = { success: response.succeeded, fetchResponse: response }; } requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response); - } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { + return; + } + prefetchCache.clear(); + if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error2 = new Error("Form responses must redirect to another location"); this.delegate.formSubmissionErrored(this, error2); } else { @@ -1385,19 +1734,19 @@ this.delegate.formSubmissionErrored(this, error2); } requestFinished(_request) { - var _a; this.state = FormSubmissionState.stopped; - (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.removeAttribute("disabled"); + if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); this.resetSubmitterText(); + clearBusyState(this.formElement); dispatch("turbo:submit-end", { target: this.formElement, - detail: Object.assign({ formSubmission: this }, this.result) + detail: { formSubmission: this, ...this.result } }); this.delegate.formSubmissionFinished(this); } + // Private setSubmitsWith() { - if (!this.submitter || !this.submitsWith) - return; + if (!this.submitter || !this.submitsWith) return; if (this.submitter.matches("button")) { this.originalSubmitText = this.submitter.innerHTML; this.submitter.innerHTML = this.submitsWith; @@ -1408,8 +1757,7 @@ } } resetSubmitterText() { - if (!this.submitter || !this.originalSubmitText) - return; + if (!this.submitter || !this.originalSubmitText) return; if (this.submitter.matches("button")) { this.submitter.innerHTML = this.originalSubmitText; } else if (this.submitter.matches("input")) { @@ -1424,14 +1772,13 @@ return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement); } get submitsWith() { - var _a; - return (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("data-turbo-submits-with"); + return this.submitter?.getAttribute("data-turbo-submits-with"); } }; - function buildFormData(formElement, submitter) { + function buildFormData(formElement, submitter2) { const formData = new FormData(formElement); - const name = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("name"); - const value = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("value"); + const name = submitter2?.getAttribute("name"); + const value = submitter2?.getAttribute("value"); if (name) { formData.append(name, value || ""); } @@ -1450,15 +1797,27 @@ function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected; } - function mergeFormDataEntries(url, entries) { - const searchParams = new URLSearchParams(); - for (const [name, value] of entries) { - if (value instanceof File) - continue; - searchParams.append(name, value); + function getFormAction(formElement, submitter2) { + const formElementAction = typeof formElement.action === "string" ? formElement.action : null; + if (submitter2?.hasAttribute("formaction")) { + return submitter2.getAttribute("formaction") || ""; + } else { + return formElement.getAttribute("action") || formElementAction || ""; } - url.search = searchParams.toString(); - return url; + } + function getAction(formAction, fetchMethod) { + const action = expandURL(formAction); + if (isSafe(fetchMethod)) { + action.search = ""; + } + return action; + } + function getMethod(formElement, submitter2) { + const method = submitter2?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; + return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get; + } + function getEnctype(formElement, submitter2) { + return fetchEnctypeFromString(submitter2?.getAttribute("formenctype") || formElement.enctype); } var Snapshot = class { constructor(element) { @@ -1480,14 +1839,7 @@ return this.element.isConnected; } get firstAutofocusableElement() { - const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; - for (const element of this.element.querySelectorAll("[autofocus]")) { - if (element.closest(inertDisabledOrHidden) == null) - return element; - else - continue; - } - return null; + return queryAutofocusableElement(this.element); } get permanentElements() { return queryPermanentElementsAll(this.element); @@ -1514,23 +1866,8 @@ return node.querySelectorAll("[id][data-turbo-permanent]"); } var FormSubmitObserver = class { + started = false; constructor(delegate, eventTarget) { - this.started = false; - this.submitCaptured = () => { - this.eventTarget.removeEventListener("submit", this.submitBubbled, false); - this.eventTarget.addEventListener("submit", this.submitBubbled, false); - }; - this.submitBubbled = (event) => { - if (!event.defaultPrevented) { - const form = event.target instanceof HTMLFormElement ? event.target : void 0; - const submitter = event.submitter || void 0; - if (form && submissionDoesNotDismissDialog(form, submitter) && submissionDoesNotTargetIFrame(form, submitter) && this.delegate.willSubmitForm(form, submitter)) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.delegate.formSubmitted(form, submitter); - } - } - }; this.delegate = delegate; this.eventTarget = eventTarget; } @@ -1546,32 +1883,40 @@ this.started = false; } } + submitCaptured = () => { + this.eventTarget.removeEventListener("submit", this.submitBubbled, false); + this.eventTarget.addEventListener("submit", this.submitBubbled, false); + }; + submitBubbled = (event) => { + if (!event.defaultPrevented) { + const form = event.target instanceof HTMLFormElement ? event.target : void 0; + const submitter2 = event.submitter || void 0; + if (form && submissionDoesNotDismissDialog(form, submitter2) && submissionDoesNotTargetIFrame(form, submitter2) && this.delegate.willSubmitForm(form, submitter2)) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.delegate.formSubmitted(form, submitter2); + } + } + }; }; - function submissionDoesNotDismissDialog(form, submitter) { - const method = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formmethod")) || form.getAttribute("method"); + function submissionDoesNotDismissDialog(form, submitter2) { + const method = submitter2?.getAttribute("formmethod") || form.getAttribute("method"); return method != "dialog"; } - function submissionDoesNotTargetIFrame(form, submitter) { - if ((submitter === null || submitter === void 0 ? void 0 : submitter.hasAttribute("formtarget")) || form.hasAttribute("target")) { - const target = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formtarget")) || form.target; - for (const element of document.getElementsByName(target)) { - if (element instanceof HTMLIFrameElement) - return false; - } - return true; - } else { - return true; - } + function submissionDoesNotTargetIFrame(form, submitter2) { + const target = submitter2?.getAttribute("formtarget") || form.getAttribute("target"); + return doesNotTargetIFrame(target); } var View = class { + #resolveRenderPromise = (_value) => { + }; + #resolveInterceptionPromise = (_value) => { + }; constructor(delegate, element) { - this.resolveRenderPromise = (_value) => { - }; - this.resolveInterceptionPromise = (_value) => { - }; this.delegate = delegate; this.element = element; } + // Scrolling scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor); if (element) { @@ -1607,28 +1952,29 @@ get scrollRoot() { return window; } + // Rendering async render(renderer) { - const { isPreview, shouldRender, newSnapshot: snapshot } = renderer; + const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer; + const shouldInvalidate = willRender; if (shouldRender) { try { - this.renderPromise = new Promise((resolve) => this.resolveRenderPromise = resolve); + this.renderPromise = new Promise((resolve) => this.#resolveRenderPromise = resolve); this.renderer = renderer; await this.prepareToRenderSnapshot(renderer); - const renderInterception = new Promise((resolve) => this.resolveInterceptionPromise = resolve); - const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement }; + const renderInterception = new Promise((resolve) => this.#resolveInterceptionPromise = resolve); + const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod }; const immediateRender = this.delegate.allowsImmediateRender(snapshot, options); - if (!immediateRender) - await renderInterception; + if (!immediateRender) await renderInterception; await this.renderSnapshot(renderer); - this.delegate.viewRenderedSnapshot(snapshot, isPreview); + this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod); this.delegate.preloadOnLoadLinksForView(this.element); this.finishRenderingSnapshot(renderer); } finally { delete this.renderer; - this.resolveRenderPromise(void 0); + this.#resolveRenderPromise(void 0); delete this.renderPromise; } - } else { + } else if (shouldInvalidate) { this.invalidate(renderer.reloadReason); } } @@ -1646,6 +1992,12 @@ this.element.removeAttribute("data-turbo-preview"); } } + markVisitDirection(direction) { + this.element.setAttribute("data-turbo-visit-direction", direction); + } + unmarkVisitDirection() { + this.element.removeAttribute("data-turbo-visit-direction"); + } async renderSnapshot(renderer) { await renderer.render(); } @@ -1663,26 +2015,6 @@ }; var LinkInterceptor = class { constructor(delegate, element) { - this.clickBubbled = (event) => { - if (this.respondsToEventTarget(event.target)) { - this.clickEvent = event; - } else { - delete this.clickEvent; - } - }; - this.linkClicked = (event) => { - if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { - if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { - this.clickEvent.preventDefault(); - event.preventDefault(); - this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); - } - } - delete this.clickEvent; - }; - this.willVisit = (_event) => { - delete this.clickEvent; - }; this.delegate = delegate; this.element = element; } @@ -1696,31 +2028,35 @@ document.removeEventListener("turbo:click", this.linkClicked); document.removeEventListener("turbo:before-visit", this.willVisit); } - respondsToEventTarget(target) { - const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; - return element && element.closest("turbo-frame, html") == this.element; + clickBubbled = (event) => { + if (this.clickEventIsSignificant(event)) { + this.clickEvent = event; + } else { + delete this.clickEvent; + } + }; + linkClicked = (event) => { + if (this.clickEvent && this.clickEventIsSignificant(event)) { + if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { + this.clickEvent.preventDefault(); + event.preventDefault(); + this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); + } + } + delete this.clickEvent; + }; + willVisit = (_event) => { + delete this.clickEvent; + }; + clickEventIsSignificant(event) { + const target = event.composed ? event.target?.parentElement : event.target; + const element = findLinkFromClickTarget(target) || target; + return element instanceof Element && element.closest("turbo-frame, html") == this.element; } }; var LinkClickObserver = class { + started = false; constructor(delegate, eventTarget) { - this.started = false; - this.clickCaptured = () => { - this.eventTarget.removeEventListener("click", this.clickBubbled, false); - this.eventTarget.addEventListener("click", this.clickBubbled, false); - }; - this.clickBubbled = (event) => { - if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { - const target = event.composedPath && event.composedPath()[0] || event.target; - const link = this.findLinkFromClickTarget(target); - if (link && doesNotTargetIFrame(link)) { - const location2 = this.getLocationForLink(link); - if (this.delegate.willFollowLinkToLocation(link, location2, event)) { - event.preventDefault(); - this.delegate.followedLinkToLocation(link, location2); - } - } - } - }; this.delegate = delegate; this.eventTarget = eventTarget; } @@ -1736,27 +2072,27 @@ this.started = false; } } + clickCaptured = () => { + this.eventTarget.removeEventListener("click", this.clickBubbled, false); + this.eventTarget.addEventListener("click", this.clickBubbled, false); + }; + clickBubbled = (event) => { + if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { + const target = event.composedPath && event.composedPath()[0] || event.target; + const link = findLinkFromClickTarget(target); + if (link && doesNotTargetIFrame(link.target)) { + const location2 = getLocationForLink(link); + if (this.delegate.willFollowLinkToLocation(link, location2, event)) { + event.preventDefault(); + this.delegate.followedLinkToLocation(link, location2); + } + } + } + }; clickEventIsSignificant(event) { return !(event.target && event.target.isContentEditable || event.defaultPrevented || event.which > 1 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey); } - findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])"); - } - getLocationForLink(link) { - return expandURL(link.getAttribute("href") || ""); - } }; - function doesNotTargetIFrame(anchor) { - if (anchor.hasAttribute("target")) { - for (const element of document.getElementsByName(anchor.target)) { - if (element instanceof HTMLIFrameElement) - return false; - } - return true; - } else { - return true; - } - } var FormLinkClickObserver = class { constructor(delegate, element) { this.delegate = delegate; @@ -1768,8 +2104,16 @@ stop() { this.linkInterceptor.stop(); } + // Link hover observer delegate + canPrefetchRequestToLocation(link, location2) { + return false; + } + prefetchAndCacheRequestToLocation(link, location2) { + return; + } + // Link click observer delegate willFollowLinkToLocation(link, location2, originalEvent) { - return this.delegate.willSubmitFormLinkToLocation(link, location2, originalEvent) && link.hasAttribute("data-turbo-method"); + return this.delegate.willSubmitFormLinkToLocation(link, location2, originalEvent) && (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream")); } followedLinkToLocation(link, location2) { const form = document.createElement("form"); @@ -1782,20 +2126,15 @@ form.setAttribute("action", action.href); form.setAttribute("hidden", ""); const method = link.getAttribute("data-turbo-method"); - if (method) - form.setAttribute("method", method); + if (method) form.setAttribute("method", method); const turboFrame = link.getAttribute("data-turbo-frame"); - if (turboFrame) - form.setAttribute("data-turbo-frame", turboFrame); + if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame); const turboAction = getVisitAction(link); - if (turboAction) - form.setAttribute("data-turbo-action", turboAction); + if (turboAction) form.setAttribute("data-turbo-action", turboAction); const turboConfirm = link.getAttribute("data-turbo-confirm"); - if (turboConfirm) - form.setAttribute("data-turbo-confirm", turboConfirm); + if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm); const turboStream = link.hasAttribute("data-turbo-stream"); - if (turboStream) - form.setAttribute("data-turbo-stream", ""); + if (turboStream) form.setAttribute("data-turbo-stream", ""); this.delegate.submittedFormLinkToLocation(link, location2, form); document.body.appendChild(form); form.addEventListener("turbo:submit-end", () => form.remove(), { once: true }); @@ -1838,7 +2177,7 @@ } replacePlaceholderWithPermanentElement(permanentElement) { const placeholder = this.getPlaceholderById(permanentElement.id); - placeholder === null || placeholder === void 0 ? void 0 : placeholder.replaceWith(permanentElement); + placeholder?.replaceWith(permanentElement); } getPlaceholderById(id2) { return this.placeholders.find((element) => element.content == id2); @@ -1854,24 +2193,31 @@ return element; } var Renderer = class { - constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { - this.activeElement = null; + #activeElement = null; + static renderElement(currentElement, newElement) { + } + constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot; this.newSnapshot = newSnapshot; this.isPreview = isPreview; this.willRender = willRender; - this.renderElement = renderElement; + this.renderElement = this.constructor.renderElement; this.promise = new Promise((resolve, reject) => this.resolvingFunctions = { resolve, reject }); } get shouldRender() { return true; } + get shouldAutofocus() { + return true; + } get reloadReason() { return; } prepareToRender() { return; } + render() { + } finishRendering() { if (this.resolvingFunctions) { this.resolvingFunctions.resolve(); @@ -1882,112 +2228,718 @@ await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback); } focusFirstAutofocusableElement() { - const element = this.connectedSnapshot.firstAutofocusableElement; - if (elementIsFocusable(element)) { - element.focus(); + if (this.shouldAutofocus) { + const element = this.connectedSnapshot.firstAutofocusableElement; + if (element) { + element.focus(); + } + } + } + // Bardo delegate + enteringBardo(currentPermanentElement) { + if (this.#activeElement) return; + if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { + this.#activeElement = this.currentSnapshot.activeElement; + } + } + leavingBardo(currentPermanentElement) { + if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { + this.#activeElement.focus(); + this.#activeElement = null; + } + } + get connectedSnapshot() { + return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot; + } + get currentElement() { + return this.currentSnapshot.element; + } + get newElement() { + return this.newSnapshot.element; + } + get permanentElementMap() { + return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot); + } + get renderMethod() { + return "replace"; + } + }; + var FrameRenderer = class extends Renderer { + static renderElement(currentElement, newElement) { + const destinationRange = document.createRange(); + destinationRange.selectNodeContents(currentElement); + destinationRange.deleteContents(); + const frameElement = newElement; + const sourceRange = frameElement.ownerDocument?.createRange(); + if (sourceRange) { + sourceRange.selectNodeContents(frameElement); + currentElement.appendChild(sourceRange.extractContents()); + } + } + constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); + this.delegate = delegate; + } + get shouldRender() { + return true; + } + async render() { + await nextRepaint(); + this.preservingPermanentElements(() => { + this.loadFrameElement(); + }); + this.scrollFrameIntoView(); + await nextRepaint(); + this.focusFirstAutofocusableElement(); + await nextRepaint(); + this.activateScriptElements(); + } + loadFrameElement() { + this.delegate.willRenderFrame(this.currentElement, this.newElement); + this.renderElement(this.currentElement, this.newElement); + } + scrollFrameIntoView() { + if (this.currentElement.autoscroll || this.newElement.autoscroll) { + const element = this.currentElement.firstElementChild; + const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); + const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); + if (element) { + element.scrollIntoView({ block, behavior }); + return true; + } + } + return false; + } + activateScriptElements() { + for (const inertScriptElement of this.newScriptElements) { + const activatedScriptElement = activateScriptElement(inertScriptElement); + inertScriptElement.replaceWith(activatedScriptElement); + } + } + get newScriptElements() { + return this.currentElement.querySelectorAll("script"); + } + }; + function readScrollLogicalPosition(value, defaultValue) { + if (value == "end" || value == "start" || value == "center" || value == "nearest") { + return value; + } else { + return defaultValue; + } + } + function readScrollBehavior(value, defaultValue) { + if (value == "auto" || value == "smooth") { + return value; + } else { + return defaultValue; + } + } + var Idiomorph = /* @__PURE__ */ function() { + let EMPTY_SET = /* @__PURE__ */ new Set(); + let defaults = { + morphStyle: "outerHTML", + callbacks: { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp + }, + head: { + style: "merge", + shouldPreserve: function(elt) { + return elt.getAttribute("im-preserve") === "true"; + }, + shouldReAppend: function(elt) { + return elt.getAttribute("im-re-append") === "true"; + }, + shouldRemove: noOp, + afterHeadMorphed: noOp + } + }; + function morph(oldNode, newContent, config2 = {}) { + if (oldNode instanceof Document) { + oldNode = oldNode.documentElement; + } + if (typeof newContent === "string") { + newContent = parseContent(newContent); + } + let normalizedContent = normalizeContent(newContent); + let ctx = createMorphContext(oldNode, normalizedContent, config2); + return morphNormalizedContent(oldNode, normalizedContent, ctx); + } + function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { + if (ctx.head.block) { + let oldHead = oldNode.querySelector("head"); + let newHead = normalizedNewContent.querySelector("head"); + if (oldHead && newHead) { + let promises = handleHeadElement(newHead, oldHead, ctx); + Promise.all(promises).then(function() { + morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { + head: { + block: false, + ignore: true + } + })); + }); + return; + } + } + if (ctx.morphStyle === "innerHTML") { + morphChildren2(normalizedNewContent, oldNode, ctx); + return oldNode.children; + } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { + let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); + let previousSibling = bestMatch?.previousSibling; + let nextSibling = bestMatch?.nextSibling; + let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); + if (bestMatch) { + return insertSiblings(previousSibling, morphedNode, nextSibling); + } else { + return []; + } + } else { + throw "Do not understand how to morph style " + ctx.morphStyle; + } + } + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; + } + function morphOldNodeTo(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) ; + else if (newContent == null) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + oldNode.remove(); + ctx.callbacks.afterNodeRemoved(oldNode); + return null; + } else if (!isSoftMatch(oldNode, newContent)) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; + oldNode.parentElement.replaceChild(newContent, oldNode); + ctx.callbacks.afterNodeAdded(newContent); + ctx.callbacks.afterNodeRemoved(oldNode); + return newContent; + } else { + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; + else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { + handleHeadElement(newContent, oldNode, ctx); + } else { + syncNodeFrom(newContent, oldNode, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + morphChildren2(newContent, oldNode, ctx); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + } + function morphChildren2(newParent, oldParent, ctx) { + let nextNewChild = newParent.firstChild; + let insertionPoint = oldParent.firstChild; + let newChild; + while (nextNewChild) { + newChild = nextNewChild; + nextNewChild = newChild.nextSibling; + if (insertionPoint == null) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + oldParent.appendChild(newChild); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + continue; + } + if (isIdSetMatch(newChild, insertionPoint, ctx)) { + morphOldNodeTo(insertionPoint, newChild, ctx); + insertionPoint = insertionPoint.nextSibling; + removeIdsFromConsideration(ctx, newChild); + continue; + } + let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); + if (idSetMatch) { + insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + morphOldNodeTo(idSetMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); + if (softMatch) { + insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + morphOldNodeTo(softMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + oldParent.insertBefore(newChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + } + while (insertionPoint !== null) { + let tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(tempNode, ctx); + } + } + function ignoreAttribute(attr, to, updateType, ctx) { + if (attr === "value" && ctx.ignoreActiveValue && to === document.activeElement) { + return true; + } + return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; + } + function syncNodeFrom(from, to, ctx) { + let type = from.nodeType; + if (type === 1) { + const fromAttributes = from.attributes; + const toAttributes = to.attributes; + for (const fromAttribute of fromAttributes) { + if (ignoreAttribute(fromAttribute.name, to, "update", ctx)) { + continue; + } + if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { + to.setAttribute(fromAttribute.name, fromAttribute.value); + } + } + for (let i = toAttributes.length - 1; 0 <= i; i--) { + const toAttribute = toAttributes[i]; + if (ignoreAttribute(toAttribute.name, to, "remove", ctx)) { + continue; + } + if (!from.hasAttribute(toAttribute.name)) { + to.removeAttribute(toAttribute.name); + } + } + } + if (type === 8 || type === 3) { + if (to.nodeValue !== from.nodeValue) { + to.nodeValue = from.nodeValue; + } + } + if (!ignoreValueOfActiveElement(to, ctx)) { + syncInputValue(from, to, ctx); + } + } + function syncBooleanAttribute(from, to, attributeName, ctx) { + if (from[attributeName] !== to[attributeName]) { + let ignoreUpdate = ignoreAttribute(attributeName, to, "update", ctx); + if (!ignoreUpdate) { + to[attributeName] = from[attributeName]; + } + if (from[attributeName]) { + if (!ignoreUpdate) { + to.setAttribute(attributeName, from[attributeName]); + } + } else { + if (!ignoreAttribute(attributeName, to, "remove", ctx)) { + to.removeAttribute(attributeName); + } + } + } + } + function syncInputValue(from, to, ctx) { + if (from instanceof HTMLInputElement && to instanceof HTMLInputElement && from.type !== "file") { + let fromValue = from.value; + let toValue = to.value; + syncBooleanAttribute(from, to, "checked", ctx); + syncBooleanAttribute(from, to, "disabled", ctx); + if (!from.hasAttribute("value")) { + if (!ignoreAttribute("value", to, "remove", ctx)) { + to.value = ""; + to.removeAttribute("value"); + } + } else if (fromValue !== toValue) { + if (!ignoreAttribute("value", to, "update", ctx)) { + to.setAttribute("value", fromValue); + to.value = fromValue; + } + } + } else if (from instanceof HTMLOptionElement) { + syncBooleanAttribute(from, to, "selected", ctx); + } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { + let fromValue = from.value; + let toValue = to.value; + if (ignoreAttribute("value", to, "update", ctx)) { + return; + } + if (fromValue !== toValue) { + to.value = fromValue; + } + if (to.firstChild && to.firstChild.nodeValue !== fromValue) { + to.firstChild.nodeValue = fromValue; + } + } + } + function handleHeadElement(newHeadTag, currentHead, ctx) { + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + let headMergeStyle = ctx.head.style; + let srcToNewHeadNodes = /* @__PURE__ */ new Map(); + for (const newHeadChild of newHeadTag.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + for (const currentHeadElt of currentHead.children) { + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + removed.push(currentHeadElt); + } else { + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (headMergeStyle === "append") { + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + nodesToAppend.push(...srcToNewHeadNodes.values()); + let promises = []; + for (const newNode of nodesToAppend) { + let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if (newElt.href || newElt.src) { + let resolve = null; + let promise = new Promise(function(_resolve) { + resolve = _resolve; + }); + newElt.addEventListener("load", function() { + resolve(); + }); + promises.push(promise); + } + currentHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + currentHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + ctx.head.afterHeadMorphed(currentHead, { added, kept: preserved, removed }); + return promises; + } + function noOp() { + } + function mergeDefaults(config2) { + let finalConfig = {}; + Object.assign(finalConfig, defaults); + Object.assign(finalConfig, config2); + finalConfig.callbacks = {}; + Object.assign(finalConfig.callbacks, defaults.callbacks); + Object.assign(finalConfig.callbacks, config2.callbacks); + finalConfig.head = {}; + Object.assign(finalConfig.head, defaults.head); + Object.assign(finalConfig.head, config2.head); + return finalConfig; + } + function createMorphContext(oldNode, newContent, config2) { + config2 = mergeDefaults(config2); + return { + target: oldNode, + newContent, + config: config2, + morphStyle: config2.morphStyle, + ignoreActive: config2.ignoreActive, + ignoreActiveValue: config2.ignoreActiveValue, + idMap: createIdMap(oldNode, newContent), + deadIds: /* @__PURE__ */ new Set(), + callbacks: config2.callbacks, + head: config2.head + }; + } + function isIdSetMatch(node1, node2, ctx) { + if (node1 == null || node2 == null) { + return false; + } + if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { + if (node1.id !== "" && node1.id === node2.id) { + return true; + } else { + return getIdIntersectionCount(ctx, node1, node2) > 0; + } } + return false; } - enteringBardo(currentPermanentElement) { - if (this.activeElement) - return; - if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { - this.activeElement = this.currentSnapshot.activeElement; + function isSoftMatch(node1, node2) { + if (node1 == null || node2 == null) { + return false; } - } - leavingBardo(currentPermanentElement) { - if (currentPermanentElement.contains(this.activeElement) && this.activeElement instanceof HTMLElement) { - this.activeElement.focus(); - this.activeElement = null; + return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName; + } + function removeNodesBetween(startInclusive, endExclusive, ctx) { + while (startInclusive !== endExclusive) { + let tempNode = startInclusive; + startInclusive = startInclusive.nextSibling; + removeNode(tempNode, ctx); + } + removeIdsFromConsideration(ctx, endExclusive); + return endExclusive.nextSibling; + } + function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { + let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); + let potentialMatch = null; + if (newChildPotentialIdCount > 0) { + let potentialMatch2 = insertionPoint; + let otherMatchCount = 0; + while (potentialMatch2 != null) { + if (isIdSetMatch(newChild, potentialMatch2, ctx)) { + return potentialMatch2; + } + otherMatchCount += getIdIntersectionCount(ctx, potentialMatch2, newContent); + if (otherMatchCount > newChildPotentialIdCount) { + return null; + } + potentialMatch2 = potentialMatch2.nextSibling; + } } + return potentialMatch; } - get connectedSnapshot() { - return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot; - } - get currentElement() { - return this.currentSnapshot.element; + function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { + let potentialSoftMatch = insertionPoint; + let nextSibling = newChild.nextSibling; + let siblingSoftMatchCount = 0; + while (potentialSoftMatch != null) { + if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { + return null; + } + if (isSoftMatch(newChild, potentialSoftMatch)) { + return potentialSoftMatch; + } + if (isSoftMatch(nextSibling, potentialSoftMatch)) { + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + if (siblingSoftMatchCount >= 2) { + return null; + } + } + potentialSoftMatch = potentialSoftMatch.nextSibling; + } + return potentialSoftMatch; } - get newElement() { - return this.newSnapshot.element; + function parseContent(newContent) { + let parser = new DOMParser(); + let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ""); + if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { + let content = parser.parseFromString(newContent, "text/html"); + if (contentWithSvgsRemoved.match(/<\/html>/)) { + content.generatedByIdiomorph = true; + return content; + } else { + let htmlElement = content.firstChild; + if (htmlElement) { + htmlElement.generatedByIdiomorph = true; + return htmlElement; + } else { + return null; + } + } + } else { + let responseDoc = parser.parseFromString("", "text/html"); + let content = responseDoc.body.querySelector("template").content; + content.generatedByIdiomorph = true; + return content; + } + } + function normalizeContent(newContent) { + if (newContent == null) { + const dummyParent = document.createElement("div"); + return dummyParent; + } else if (newContent.generatedByIdiomorph) { + return newContent; + } else if (newContent instanceof Node) { + const dummyParent = document.createElement("div"); + dummyParent.append(newContent); + return dummyParent; + } else { + const dummyParent = document.createElement("div"); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } } - get permanentElementMap() { - return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot); + function insertSiblings(previousSibling, morphedNode, nextSibling) { + let stack = []; + let added = []; + while (previousSibling != null) { + stack.push(previousSibling); + previousSibling = previousSibling.previousSibling; + } + while (stack.length > 0) { + let node = stack.pop(); + added.push(node); + morphedNode.parentElement.insertBefore(node, morphedNode); + } + added.push(morphedNode); + while (nextSibling != null) { + stack.push(nextSibling); + added.push(nextSibling); + nextSibling = nextSibling.nextSibling; + } + while (stack.length > 0) { + morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); + } + return added; } - }; - function elementIsFocusable(element) { - return element && typeof element.focus == "function"; - } - var FrameRenderer = class extends Renderer { - static renderElement(currentElement, newElement) { - var _a; - const destinationRange = document.createRange(); - destinationRange.selectNodeContents(currentElement); - destinationRange.deleteContents(); - const frameElement = newElement; - const sourceRange = (_a = frameElement.ownerDocument) === null || _a === void 0 ? void 0 : _a.createRange(); - if (sourceRange) { - sourceRange.selectNodeContents(frameElement); - currentElement.appendChild(sourceRange.extractContents()); + function findBestNodeMatch(newContent, oldNode, ctx) { + let currentElement; + currentElement = newContent.firstChild; + let bestElement = currentElement; + let score = 0; + while (currentElement) { + let newScore = scoreElement(currentElement, oldNode, ctx); + if (newScore > score) { + bestElement = currentElement; + score = newScore; + } + currentElement = currentElement.nextSibling; } + return bestElement; } - constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { - super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); - this.delegate = delegate; + function scoreElement(node1, node2, ctx) { + if (isSoftMatch(node1, node2)) { + return 0.5 + getIdIntersectionCount(ctx, node1, node2); + } + return 0; } - get shouldRender() { - return true; + function removeNode(tempNode, ctx) { + removeIdsFromConsideration(ctx, tempNode); + if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; + tempNode.remove(); + ctx.callbacks.afterNodeRemoved(tempNode); + } + function isIdInConsideration(ctx, id2) { + return !ctx.deadIds.has(id2); + } + function idIsWithinNode(ctx, id2, targetNode) { + let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; + return idSet.has(id2); + } + function removeIdsFromConsideration(ctx, node) { + let idSet = ctx.idMap.get(node) || EMPTY_SET; + for (const id2 of idSet) { + ctx.deadIds.add(id2); + } + } + function getIdIntersectionCount(ctx, node1, node2) { + let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; + let matchCount = 0; + for (const id2 of sourceSet) { + if (isIdInConsideration(ctx, id2) && idIsWithinNode(ctx, id2, node2)) { + ++matchCount; + } + } + return matchCount; + } + function populateIdMapForNode(node, idMap) { + let nodeParent = node.parentElement; + let idElements = node.querySelectorAll("[id]"); + for (const elt of idElements) { + let current = elt; + while (current !== nodeParent && current != null) { + let idSet = idMap.get(current); + if (idSet == null) { + idSet = /* @__PURE__ */ new Set(); + idMap.set(current, idSet); + } + idSet.add(elt.id); + current = current.parentElement; + } + } } - async render() { - await nextAnimationFrame(); - this.preservingPermanentElements(() => { - this.loadFrameElement(); - }); - this.scrollFrameIntoView(); - await nextAnimationFrame(); - this.focusFirstAutofocusableElement(); - await nextAnimationFrame(); - this.activateScriptElements(); + function createIdMap(oldContent, newContent) { + let idMap = /* @__PURE__ */ new Map(); + populateIdMapForNode(oldContent, idMap); + populateIdMapForNode(newContent, idMap); + return idMap; } - loadFrameElement() { - this.delegate.willRenderFrame(this.currentElement, this.newElement); - this.renderElement(this.currentElement, this.newElement); + return { + morph, + defaults + }; + }(); + function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { + Idiomorph.morph(currentElement, newElement, { + ...options, + callbacks: new DefaultIdiomorphCallbacks(callbacks) + }); + } + function morphChildren(currentElement, newElement) { + morphElements(currentElement, newElement.children, { + morphStyle: "innerHTML" + }); + } + var DefaultIdiomorphCallbacks = class { + #beforeNodeMorphed; + constructor({ beforeNodeMorphed } = {}) { + this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); } - scrollFrameIntoView() { - if (this.currentElement.autoscroll || this.newElement.autoscroll) { - const element = this.currentElement.firstElementChild; - const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); - const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); - if (element) { - element.scrollIntoView({ block, behavior }); - return true; + beforeNodeAdded = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)); + }; + beforeNodeMorphed = (currentElement, newElement) => { + if (currentElement instanceof Element) { + if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target: currentElement, + detail: { currentElement, newElement } + }); + return !event.defaultPrevented; + } else { + return false; } } - return false; - } - activateScriptElements() { - for (const inertScriptElement of this.newScriptElements) { - const activatedScriptElement = activateScriptElement(inertScriptElement); - inertScriptElement.replaceWith(activatedScriptElement); + }; + beforeAttributeUpdated = (attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target, + detail: { attributeName, mutationType } + }); + return !event.defaultPrevented; + }; + beforeNodeRemoved = (node) => { + return this.beforeNodeMorphed(node); + }; + afterNodeMorphed = (currentElement, newElement) => { + if (currentElement instanceof Element) { + dispatch("turbo:morph-element", { + target: currentElement, + detail: { currentElement, newElement } + }); } - } - get newScriptElements() { - return this.currentElement.querySelectorAll("script"); - } + }; }; - function readScrollLogicalPosition(value, defaultValue) { - if (value == "end" || value == "start" || value == "center" || value == "nearest") { - return value; - } else { - return defaultValue; + var MorphingFrameRenderer = class extends FrameRenderer { + static renderElement(currentElement, newElement) { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }); + morphChildren(currentElement, newElement); } - } - function readScrollBehavior(value, defaultValue) { - if (value == "auto" || value == "smooth") { - return value; - } else { - return defaultValue; + async preservingPermanentElements(callback) { + return await callback(); } - } + }; var ProgressBar = class _ProgressBar { + static animationDuration = 300; + /*ms*/ static get defaultCSS() { return unindent` .turbo-progress-bar { @@ -2005,13 +2957,10 @@ } `; } + hiding = false; + value = 0; + visible = false; constructor() { - this.hiding = false; - this.value = 0; - this.visible = false; - this.trickle = () => { - this.setValue(this.value + Math.random() / 100); - }; this.stylesheetElement = this.createStylesheetElement(); this.progressElement = this.createProgressElement(); this.installStylesheetElement(); @@ -2039,6 +2988,7 @@ this.value = value; this.refresh(); } + // Private installStylesheetElement() { document.head.insertBefore(this.stylesheetElement, document.head.firstChild); } @@ -2066,6 +3016,9 @@ window.clearInterval(this.trickleInterval); delete this.trickleInterval; } + trickle = () => { + this.setValue(this.value + Math.random() / 100); + }; refresh() { requestAnimationFrame(() => { this.progressElement.style.width = `${10 + this.value * 90}%`; @@ -2075,8 +3028,9 @@ const element = document.createElement("style"); element.type = "text/css"; element.textContent = _ProgressBar.defaultCSS; - if (this.cspNonce) { - element.nonce = this.cspNonce; + const cspNonce = getCspNonce(); + if (cspNonce) { + element.nonce = cspNonce; } return element; } @@ -2085,24 +3039,23 @@ element.className = "turbo-progress-bar"; return element; } - get cspNonce() { - return getMetaContent("csp-nonce"); - } }; - ProgressBar.animationDuration = 300; var HeadSnapshot = class extends Snapshot { - constructor() { - super(...arguments); - this.detailsByOuterHTML = this.children.filter((element) => !elementIsNoscript(element)).map((element) => elementWithoutNonce(element)).reduce((result, element) => { - const { outerHTML } = element; - const details = outerHTML in result ? result[outerHTML] : { - type: elementType(element), - tracked: elementIsTracked(element), - elements: [] - }; - return Object.assign(Object.assign({}, result), { [outerHTML]: Object.assign(Object.assign({}, details), { elements: [...details.elements, element] }) }); - }, {}); - } + detailsByOuterHTML = this.children.filter((element) => !elementIsNoscript(element)).map((element) => elementWithoutNonce(element)).reduce((result, element) => { + const { outerHTML } = element; + const details = outerHTML in result ? result[outerHTML] : { + type: elementType(element), + tracked: elementIsTracked(element), + elements: [] + }; + return { + ...result, + [outerHTML]: { + ...details, + elements: [...details.elements, element] + } + }; + }, {}); get trackedElementSignature() { return Object.keys(this.detailsByOuterHTML).filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked).join(""); } @@ -2133,9 +3086,11 @@ } findMetaElementByName(name) { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { - const { elements: [element] } = this.detailsByOuterHTML[outerHTML]; + const { + elements: [element] + } = this.detailsByOuterHTML[outerHTML]; return elementIsMetaElementWithName(element, name) ? element : result; - }, void 0); + }, void 0 | void 0); } }; function elementType(element) { @@ -2177,11 +3132,12 @@ static fromElement(element) { return this.fromDocument(element.ownerDocument); } - static fromDocument({ head, body }) { - return new this(body, new HeadSnapshot(head)); + static fromDocument({ documentElement, body, head }) { + return new this(documentElement, body, new HeadSnapshot(head)); } - constructor(element, headSnapshot) { - super(element); + constructor(documentElement, body, headSnapshot) { + super(body); + this.documentElement = documentElement; this.headSnapshot = headSnapshot; } clone() { @@ -2190,22 +3146,22 @@ const clonedSelectElements = clonedElement.querySelectorAll("select"); for (const [index, source] of selectElements.entries()) { const clone = clonedSelectElements[index]; - for (const option of clone.selectedOptions) - option.selected = false; - for (const option of source.selectedOptions) - clone.options[option.index].selected = true; + for (const option of clone.selectedOptions) option.selected = false; + for (const option of source.selectedOptions) clone.options[option.index].selected = true; } for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { clonedPasswordInput.value = ""; } - return new _PageSnapshot(clonedElement, this.headSnapshot); + return new _PageSnapshot(this.documentElement, clonedElement, this.headSnapshot); + } + get lang() { + return this.documentElement.getAttribute("lang"); } get headElement() { return this.headSnapshot.element; } get rootLocation() { - var _a; - const root = (_a = this.getSetting("root")) !== null && _a !== void 0 ? _a : "/"; + const root = this.getSetting("root") ?? "/"; return expandURL(root); } get cacheControlValue() { @@ -2220,25 +3176,38 @@ get isVisitable() { return this.getSetting("visit-control") != "reload"; } + get prefersViewTransitions() { + return this.headSnapshot.getMetaValue("view-transition") === "same-origin"; + } + get shouldMorphPage() { + return this.getSetting("refresh-method") === "morph"; + } + get shouldPreserveScrollPosition() { + return this.getSetting("refresh-scroll") === "preserve"; + } + // Private getSetting(name) { return this.headSnapshot.getMetaValue(`turbo-${name}`); } }; - var TimingMetric; - (function(TimingMetric2) { - TimingMetric2["visitStart"] = "visitStart"; - TimingMetric2["requestStart"] = "requestStart"; - TimingMetric2["requestEnd"] = "requestEnd"; - TimingMetric2["visitEnd"] = "visitEnd"; - })(TimingMetric || (TimingMetric = {})); - var VisitState; - (function(VisitState2) { - VisitState2["initialized"] = "initialized"; - VisitState2["started"] = "started"; - VisitState2["canceled"] = "canceled"; - VisitState2["failed"] = "failed"; - VisitState2["completed"] = "completed"; - })(VisitState || (VisitState = {})); + var ViewTransitioner = class { + #viewTransitionStarted = false; + #lastOperation = Promise.resolve(); + renderChange(useViewTransition, render) { + if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) { + this.#viewTransitionStarted = true; + this.#lastOperation = this.#lastOperation.then(async () => { + await document.startViewTransition(render).finished; + }); + } else { + this.#lastOperation = this.#lastOperation.then(render); + } + return this.#lastOperation; + } + get viewTransitionsAvailable() { + return document.startViewTransition; + } + }; var defaultOptions = { action: "advance", historyChanged: false, @@ -2249,27 +3218,62 @@ shouldCacheSnapshot: true, acceptsStreamResponse: false }; - var SystemStatusCode; - (function(SystemStatusCode2) { - SystemStatusCode2[SystemStatusCode2["networkFailure"] = 0] = "networkFailure"; - SystemStatusCode2[SystemStatusCode2["timeoutFailure"] = -1] = "timeoutFailure"; - SystemStatusCode2[SystemStatusCode2["contentTypeMismatch"] = -2] = "contentTypeMismatch"; - })(SystemStatusCode || (SystemStatusCode = {})); + var TimingMetric = { + visitStart: "visitStart", + requestStart: "requestStart", + requestEnd: "requestEnd", + visitEnd: "visitEnd" + }; + var VisitState = { + initialized: "initialized", + started: "started", + canceled: "canceled", + failed: "failed", + completed: "completed" + }; + var SystemStatusCode = { + networkFailure: 0, + timeoutFailure: -1, + contentTypeMismatch: -2 + }; + var Direction = { + advance: "forward", + restore: "back", + replace: "none" + }; var Visit = class { + identifier = uuid(); + // Required by turbo-ios + timingMetrics = {}; + followedRedirect = false; + historyChanged = false; + scrolled = false; + shouldCacheSnapshot = true; + acceptsStreamResponse = false; + snapshotCached = false; + state = VisitState.initialized; + viewTransitioner = new ViewTransitioner(); constructor(delegate, location2, restorationIdentifier, options = {}) { - this.identifier = uuid(); - this.timingMetrics = {}; - this.followedRedirect = false; - this.historyChanged = false; - this.scrolled = false; - this.shouldCacheSnapshot = true; - this.acceptsStreamResponse = false; - this.snapshotCached = false; - this.state = VisitState.initialized; this.delegate = delegate; this.location = location2; this.restorationIdentifier = restorationIdentifier || uuid(); - const { action, historyChanged, referrer, snapshot, snapshotHTML, response, visitCachedSnapshot, willRender, updateHistory, shouldCacheSnapshot, acceptsStreamResponse } = Object.assign(Object.assign({}, defaultOptions), options); + const { + action, + historyChanged, + referrer, + snapshot, + snapshotHTML, + response, + visitCachedSnapshot, + willRender, + updateHistory, + shouldCacheSnapshot, + acceptsStreamResponse, + direction + } = { + ...defaultOptions, + ...options + }; this.action = action; this.historyChanged = historyChanged; this.referrer = referrer; @@ -2277,12 +3281,14 @@ this.snapshotHTML = snapshotHTML; this.response = response; this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action); + this.isPageRefresh = this.view.isPageRefresh(this); this.visitCachedSnapshot = visitCachedSnapshot; this.willRender = willRender; this.updateHistory = updateHistory; this.scrolled = !willRender; this.shouldCacheSnapshot = shouldCacheSnapshot; this.acceptsStreamResponse = acceptsStreamResponse; + this.direction = direction || Direction[action]; } get adapter() { return this.delegate.adapter; @@ -2319,10 +3325,10 @@ complete() { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd); + this.adapter.visitCompleted(this); this.state = VisitState.completed; this.followRedirect(); if (!this.followedRedirect) { - this.adapter.visitCompleted(this); this.delegate.visitCompleted(this); } } @@ -2331,12 +3337,12 @@ if (this.state == VisitState.started) { this.state = VisitState.failed; this.adapter.visitFailed(this); + this.delegate.visitCompleted(this); } } changeHistory() { - var _a; if (!this.historyChanged && this.updateHistory) { - const actionForHistory = this.location.href === ((_a = this.referrer) === null || _a === void 0 ? void 0 : _a.href) ? "replace" : this.action; + const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action; const method = getHistoryMethodForAction(actionForHistory); this.history.update(method, this.location, this.restorationIdentifier); this.historyChanged = true; @@ -2380,13 +3386,11 @@ if (this.response) { const { statusCode, responseHTML } = this.response; this.render(async () => { - if (this.shouldCacheSnapshot) - this.cacheSnapshot(); - if (this.view.renderPromise) - await this.view.renderPromise; + if (this.shouldCacheSnapshot) this.cacheSnapshot(); + if (this.view.renderPromise) await this.view.renderPromise; if (isSuccessful(statusCode) && responseHTML != null) { - await this.view.renderPage(PageSnapshot.fromHTMLString(responseHTML), false, this.willRender, this); - this.performScroll(); + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + await this.renderPageSnapshot(snapshot, false); this.adapter.visitRendered(this); this.complete(); } else { @@ -2419,13 +3423,11 @@ const isPreview = this.shouldIssueRequest(); this.render(async () => { this.cacheSnapshot(); - if (this.isSamePage) { + if (this.isSamePage || this.isPageRefresh) { this.adapter.visitRendered(this); } else { - if (this.view.renderPromise) - await this.view.renderPromise; - await this.view.renderPage(snapshot, isPreview, this.willRender, this); - this.performScroll(); + if (this.view.renderPromise) await this.view.renderPromise; + await this.renderPageSnapshot(snapshot, isPreview); this.adapter.visitRendered(this); if (!isPreview) { this.complete(); @@ -2435,8 +3437,7 @@ } } followRedirect() { - var _a; - if (this.redirectedToLocation && !this.followedRedirect && ((_a = this.response) === null || _a === void 0 ? void 0 : _a.redirected)) { + if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { action: "replace", response: this.response, @@ -2456,6 +3457,7 @@ }); } } + // Fetch request delegate prepareRequest(request) { if (this.acceptsStreamResponse) { request.acceptResponseType(StreamMessage.contentType); @@ -2500,8 +3502,9 @@ requestFinished() { this.finishRequest(); } + // Scrolling performScroll() { - if (!this.scrolled && !this.view.forceReloaded) { + if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop(); } else { @@ -2527,12 +3530,14 @@ return true; } } + // Instrumentation recordTimingMetric(metric) { this.timingMetrics[metric] = (/* @__PURE__ */ new Date()).getTime(); } getTimingMetrics() { - return Object.assign({}, this.timingMetrics); + return { ...this.timingMetrics }; } + // Private getHistoryMethodForAction(action) { switch (action) { case "replace": @@ -2563,11 +3568,17 @@ async render(callback) { this.cancelRender(); await new Promise((resolve) => { - this.frame = requestAnimationFrame(() => resolve()); + this.frame = document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve()); }); await callback(); delete this.frame; } + async renderPageSnapshot(snapshot, isPreview) { + await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { + await this.view.renderPage(snapshot, isPreview, this.willRender, this); + this.performScroll(); + }); + } cancelRender() { if (this.frame) { cancelAnimationFrame(this.frame); @@ -2579,15 +3590,16 @@ return statusCode >= 200 && statusCode < 300; } var BrowserAdapter = class { + progressBar = new ProgressBar(); constructor(session2) { - this.progressBar = new ProgressBar(); - this.showProgressBar = () => { - this.progressBar.show(); - }; this.session = session2; } visitProposedToLocation(location2, options) { - this.navigator.startVisit(location2, (options === null || options === void 0 ? void 0 : options.restorationIdentifier) || uuid(), options); + if (locationIsVisitable(location2, this.navigator.rootLocation)) { + this.navigator.startVisit(location2, options?.restorationIdentifier || uuid(), options); + } else { + window.location.href = location2.toString(); + } } visitStarted(visit2) { this.location = visit2.location; @@ -2622,18 +3634,21 @@ } } visitRequestFinished(_visit) { - this.progressBar.setValue(1); - this.hideVisitProgressBar(); } visitCompleted(_visit) { + this.progressBar.setValue(1); + this.hideVisitProgressBar(); } pageInvalidated(reason) { this.reload(reason); } visitFailed(_visit) { + this.progressBar.setValue(1); + this.hideVisitProgressBar(); } visitRendered(_visit) { } + // Form Submission Delegate formSubmissionStarted(_formSubmission) { this.progressBar.setValue(0); this.showFormProgressBarAfterDelay(); @@ -2642,6 +3657,7 @@ this.progressBar.setValue(1); this.hideFormProgressBar(); } + // Private showVisitProgressBarAfterDelay() { this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); } @@ -2664,26 +3680,21 @@ delete this.formProgressBarTimeout; } } + showProgressBar = () => { + this.progressBar.show(); + }; reload(reason) { - var _a; dispatch("turbo:reload", { detail: reason }); - window.location.href = ((_a = this.location) === null || _a === void 0 ? void 0 : _a.toString()) || window.location.href; + window.location.href = this.location?.toString() || window.location.href; } get navigator() { return this.session.navigator; } }; var CacheObserver = class { - constructor() { - this.selector = "[data-turbo-temporary]"; - this.deprecatedSelector = "[data-turbo-cache=false]"; - this.started = false; - this.removeTemporaryElements = (_event) => { - for (const element of this.temporaryElements) { - element.remove(); - } - }; - } + selector = "[data-turbo-temporary]"; + deprecatedSelector = "[data-turbo-cache=false]"; + started = false; start() { if (!this.started) { this.started = true; @@ -2696,13 +3707,20 @@ removeEventListener("turbo:before-cache", this.removeTemporaryElements, false); } } + removeTemporaryElements = (_event) => { + for (const element of this.temporaryElements) { + element.remove(); + } + }; get temporaryElements() { return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]; } get temporaryElementsWithDeprecation() { const elements = document.querySelectorAll(this.deprecatedSelector); if (elements.length) { - console.warn(`The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.`); + console.warn( + `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.` + ); } return [...elements]; } @@ -2722,42 +3740,43 @@ this.linkInterceptor.stop(); this.formSubmitObserver.stop(); } + // Link interceptor delegate shouldInterceptLinkClick(element, _location, _event) { - return this.shouldRedirect(element); + return this.#shouldRedirect(element); } linkClickIntercepted(element, url, event) { - const frame = this.findFrameElement(element); + const frame = this.#findFrameElement(element); if (frame) { frame.delegate.linkClickIntercepted(element, url, event); } } - willSubmitForm(element, submitter) { - return element.closest("turbo-frame") == null && this.shouldSubmit(element, submitter) && this.shouldRedirect(element, submitter); + // Form submit observer delegate + willSubmitForm(element, submitter2) { + return element.closest("turbo-frame") == null && this.#shouldSubmit(element, submitter2) && this.#shouldRedirect(element, submitter2); } - formSubmitted(element, submitter) { - const frame = this.findFrameElement(element, submitter); + formSubmitted(element, submitter2) { + const frame = this.#findFrameElement(element, submitter2); if (frame) { - frame.delegate.formSubmitted(element, submitter); + frame.delegate.formSubmitted(element, submitter2); } } - shouldSubmit(form, submitter) { - var _a; - const action = getAction(form, submitter); + #shouldSubmit(form, submitter2) { + const action = getAction$1(form, submitter2); const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); - const rootLocation = expandURL((_a = meta === null || meta === void 0 ? void 0 : meta.content) !== null && _a !== void 0 ? _a : "/"); - return this.shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation); + const rootLocation = expandURL(meta?.content ?? "/"); + return this.#shouldRedirect(form, submitter2) && locationIsVisitable(action, rootLocation); } - shouldRedirect(element, submitter) { - const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter) : this.session.elementIsNavigatable(element); + #shouldRedirect(element, submitter2) { + const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter2) : this.session.elementIsNavigatable(element); if (isNavigatable) { - const frame = this.findFrameElement(element, submitter); + const frame = this.#findFrameElement(element, submitter2); return frame ? frame != element.closest("turbo-frame") : false; } else { return false; } } - findFrameElement(element, submitter) { - const id2 = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("data-turbo-frame")) || element.getAttribute("data-turbo-frame"); + #findFrameElement(element, submitter2) { + const id2 = submitter2?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame"); if (id2 && id2 != "_top") { const frame = this.element.querySelector(`#${id2}:not([disabled])`); if (frame instanceof FrameElement) { @@ -2767,32 +3786,20 @@ } }; var History = class { + location; + restorationIdentifier = uuid(); + restorationData = {}; + started = false; + pageLoaded = false; + currentIndex = 0; constructor(delegate) { - this.restorationIdentifier = uuid(); - this.restorationData = {}; - this.started = false; - this.pageLoaded = false; - this.onPopState = (event) => { - if (this.shouldHandlePopState()) { - const { turbo } = event.state || {}; - if (turbo) { - this.location = new URL(window.location.href); - const { restorationIdentifier } = turbo; - this.restorationIdentifier = restorationIdentifier; - this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier); - } - } - }; - this.onPageLoad = async (_event) => { - await nextMicrotask(); - this.pageLoaded = true; - }; this.delegate = delegate; } start() { if (!this.started) { addEventListener("popstate", this.onPopState, false); addEventListener("load", this.onPageLoad, false); + this.currentIndex = history.state?.turbo?.restorationIndex || 0; this.started = true; this.replace(new URL(window.location.href)); } @@ -2811,23 +3818,28 @@ this.update(history.replaceState, location2, restorationIdentifier); } update(method, location2, restorationIdentifier = uuid()) { - const state = { turbo: { restorationIdentifier } }; + if (method === history.pushState) ++this.currentIndex; + const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } }; method.call(history, state, "", location2.href); this.location = location2; this.restorationIdentifier = restorationIdentifier; } + // Restoration data getRestorationDataForIdentifier(restorationIdentifier) { return this.restorationData[restorationIdentifier] || {}; } updateRestorationData(additionalData) { const { restorationIdentifier } = this; const restorationData = this.restorationData[restorationIdentifier]; - this.restorationData[restorationIdentifier] = Object.assign(Object.assign({}, restorationData), additionalData); + this.restorationData[restorationIdentifier] = { + ...restorationData, + ...additionalData + }; } + // Scroll restoration assumeControlOfScrollRestoration() { - var _a; if (!this.previousScrollRestoration) { - this.previousScrollRestoration = (_a = history.scrollRestoration) !== null && _a !== void 0 ? _a : "auto"; + this.previousScrollRestoration = history.scrollRestoration ?? "auto"; history.scrollRestoration = "manual"; } } @@ -2837,6 +3849,25 @@ delete this.previousScrollRestoration; } } + // Event handlers + onPopState = (event) => { + if (this.shouldHandlePopState()) { + const { turbo } = event.state || {}; + if (turbo) { + this.location = new URL(window.location.href); + const { restorationIdentifier, restorationIndex } = turbo; + this.restorationIdentifier = restorationIdentifier; + const direction = restorationIndex > this.currentIndex ? "forward" : "back"; + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); + this.currentIndex = restorationIndex; + } + } + }; + onPageLoad = async (_event) => { + await nextMicrotask(); + this.pageLoaded = true; + }; + // Private shouldHandlePopState() { return this.pageIsLoaded(); } @@ -2844,27 +3875,166 @@ return this.pageLoaded || document.readyState == "complete"; } }; + var LinkPrefetchObserver = class { + started = false; + #prefetchedLink = null; + constructor(delegate, eventTarget) { + this.delegate = delegate; + this.eventTarget = eventTarget; + } + start() { + if (this.started) return; + if (this.eventTarget.readyState === "loading") { + this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true }); + } else { + this.#enable(); + } + } + stop() { + if (!this.started) return; + this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { + capture: true, + passive: true + }); + this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, { + capture: true, + passive: true + }); + this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); + this.started = false; + } + #enable = () => { + this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { + capture: true, + passive: true + }); + this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { + capture: true, + passive: true + }); + this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); + this.started = true; + }; + #tryToPrefetchRequest = (event) => { + if (getMetaContent("turbo-prefetch") === "false") return; + const target = event.target; + const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])"); + if (isLink && this.#isPrefetchable(target)) { + const link = target; + const location2 = getLocationForLink(link); + if (this.delegate.canPrefetchRequestToLocation(link, location2)) { + this.#prefetchedLink = link; + const fetchRequest = new FetchRequest( + this, + FetchMethod.get, + location2, + new URLSearchParams(), + target + ); + prefetchCache.setLater(location2.toString(), fetchRequest, this.#cacheTtl); + } + } + }; + #cancelRequestIfObsolete = (event) => { + if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest(); + }; + #cancelPrefetchRequest = () => { + prefetchCache.clear(); + this.#prefetchedLink = null; + }; + #tryToUsePrefetchedRequest = (event) => { + if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { + const cached = prefetchCache.get(event.detail.url.toString()); + if (cached) { + event.detail.fetchRequest = cached; + } + prefetchCache.clear(); + } + }; + prepareRequest(request) { + const link = request.target; + request.headers["X-Sec-Purpose"] = "prefetch"; + const turboFrame = link.closest("turbo-frame"); + const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id; + if (turboFrameTarget && turboFrameTarget !== "_top") { + request.headers["Turbo-Frame"] = turboFrameTarget; + } + } + // Fetch request interface + requestSucceededWithResponse() { + } + requestStarted(fetchRequest) { + } + requestErrored(fetchRequest) { + } + requestFinished(fetchRequest) { + } + requestPreventedHandlingResponse(fetchRequest, fetchResponse) { + } + requestFailedWithResponse(fetchRequest, fetchResponse) { + } + get #cacheTtl() { + return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl; + } + #isPrefetchable(link) { + const href = link.getAttribute("href"); + if (!href) return false; + if (unfetchableLink(link)) return false; + if (linkToTheSamePage(link)) return false; + if (linkOptsOut(link)) return false; + if (nonSafeLink(link)) return false; + if (eventPrevented(link)) return false; + return true; + } + }; + var unfetchableLink = (link) => { + return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target"); + }; + var linkToTheSamePage = (link) => { + return link.pathname + link.search === document.location.pathname + document.location.search || link.href.startsWith("#"); + }; + var linkOptsOut = (link) => { + if (link.getAttribute("data-turbo-prefetch") === "false") return true; + if (link.getAttribute("data-turbo") === "false") return true; + const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]"); + if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true; + return false; + }; + var nonSafeLink = (link) => { + const turboMethod = link.getAttribute("data-turbo-method"); + if (turboMethod && turboMethod.toLowerCase() !== "get") return true; + if (isUJS(link)) return true; + if (link.hasAttribute("data-turbo-confirm")) return true; + if (link.hasAttribute("data-turbo-stream")) return true; + return false; + }; + var isUJS = (link) => { + return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method"); + }; + var eventPrevented = (link) => { + const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }); + return event.defaultPrevented; + }; var Navigator = class { constructor(delegate) { this.delegate = delegate; } proposeVisit(location2, options = {}) { if (this.delegate.allowsVisitingLocationWithAction(location2, options.action)) { - if (locationIsVisitable(location2, this.view.snapshot.rootLocation)) { - this.delegate.visitProposedToLocation(location2, options); - } else { - window.location.href = location2.toString(); - } + this.delegate.visitProposedToLocation(location2, options); } } startVisit(locatable, restorationIdentifier, options = {}) { this.stop(); - this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, Object.assign({ referrer: this.location }, options)); + this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { + referrer: this.location, + ...options + }); this.currentVisit.start(); } - submitForm(form, submitter) { + submitForm(form, submitter2) { this.stop(); - this.formSubmission = new FormSubmission(this, form, submitter, true); + this.formSubmission = new FormSubmission(this, form, submitter2, true); this.formSubmission.start(); } stop() { @@ -2883,9 +4053,13 @@ get view() { return this.delegate.view; } + get rootLocation() { + return this.view.snapshot.rootLocation; + } get history() { return this.delegate.history; } + // Form submission delegate formSubmissionStarted(formSubmission) { if (typeof this.adapter.formSubmissionStarted === "function") { this.adapter.formSubmissionStarted(formSubmission); @@ -2900,7 +4074,7 @@ this.view.clearSnapshotCache(); } const { statusCode, redirected } = fetchResponse; - const action = this.getActionForFormSubmission(formSubmission); + const action = this.#getActionForFormSubmission(formSubmission, fetchResponse); const visitOptions = { action, shouldCacheSnapshot, @@ -2919,7 +4093,9 @@ } else { await this.view.renderPage(snapshot, false, true, this.currentVisit); } - this.view.scrollToTop(); + if (!snapshot.shouldPreserveScrollPosition) { + this.view.scrollToTop(); + } this.view.clearSnapshotCache(); } } @@ -2931,11 +4107,13 @@ this.adapter.formSubmissionFinished(formSubmission); } } + // Visit delegate visitStarted(visit2) { this.delegate.visitStarted(visit2); } visitCompleted(visit2) { this.delegate.visitCompleted(visit2); + delete this.currentVisit; } locationWithActionIsSamePage(location2, action) { const anchor = getAnchor(location2); @@ -2946,38 +4124,32 @@ visitScrolledToSamePageLocation(oldURL, newURL) { this.delegate.visitScrolledToSamePageLocation(oldURL, newURL); } + // Visits get location() { return this.history.location; } get restorationIdentifier() { return this.history.restorationIdentifier; } - getActionForFormSubmission({ submitter, formElement }) { - return getVisitAction(submitter, formElement) || "advance"; + #getActionForFormSubmission(formSubmission, fetchResponse) { + const { submitter: submitter2, formElement } = formSubmission; + return getVisitAction(submitter2, formElement) || this.#getDefaultAction(fetchResponse); + } + #getDefaultAction(fetchResponse) { + const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href; + return sameLocationRedirect ? "replace" : "advance"; } }; - var PageStage; - (function(PageStage2) { - PageStage2[PageStage2["initial"] = 0] = "initial"; - PageStage2[PageStage2["loading"] = 1] = "loading"; - PageStage2[PageStage2["interactive"] = 2] = "interactive"; - PageStage2[PageStage2["complete"] = 3] = "complete"; - })(PageStage || (PageStage = {})); + var PageStage = { + initial: 0, + loading: 1, + interactive: 2, + complete: 3 + }; var PageObserver = class { + stage = PageStage.initial; + started = false; constructor(delegate) { - this.stage = PageStage.initial; - this.started = false; - this.interpretReadyState = () => { - const { readyState } = this; - if (readyState == "interactive") { - this.pageIsInteractive(); - } else if (readyState == "complete") { - this.pageIsComplete(); - } - }; - this.pageWillUnload = () => { - this.delegate.pageWillUnload(); - }; this.delegate = delegate; } start() { @@ -2997,6 +4169,14 @@ this.started = false; } } + interpretReadyState = () => { + const { readyState } = this; + if (readyState == "interactive") { + this.pageIsInteractive(); + } else if (readyState == "complete") { + this.pageIsComplete(); + } + }; pageIsInteractive() { if (this.stage == PageStage.loading) { this.stage = PageStage.interactive; @@ -3010,16 +4190,16 @@ this.delegate.pageLoaded(); } } + pageWillUnload = () => { + this.delegate.pageWillUnload(); + }; get readyState() { return document.readyState; } }; var ScrollObserver = class { + started = false; constructor(delegate) { - this.started = false; - this.onScroll = () => { - this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); - }; this.delegate = delegate; } start() { @@ -3035,14 +4215,25 @@ this.started = false; } } + onScroll = () => { + this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); + }; + // Private updatePosition(position) { this.delegate.scrollPositionChanged(position); } }; var StreamMessageRenderer = class { render({ fragment }) { - Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => document.documentElement.appendChild(fragment)); + Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { + withAutofocusFromFragment(fragment, () => { + withPreservedFocus(() => { + document.documentElement.appendChild(fragment); + }); + }); + }); } + // Bardo delegate enteringBardo(currentPermanentElement, newPermanentElement) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)); } @@ -3063,33 +4254,64 @@ } return permanentElementMap; } + async function withAutofocusFromFragment(fragment, callback) { + const generatedID = `turbo-stream-autofocus-${uuid()}`; + const turboStreams = fragment.querySelectorAll("turbo-stream"); + const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams); + let willAutofocusId = null; + if (elementWithAutofocus) { + if (elementWithAutofocus.id) { + willAutofocusId = elementWithAutofocus.id; + } else { + willAutofocusId = generatedID; + } + elementWithAutofocus.id = willAutofocusId; + } + callback(); + await nextRepaint(); + const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body; + if (hasNoActiveElement && willAutofocusId) { + const elementToAutofocus = document.getElementById(willAutofocusId); + if (elementIsFocusable(elementToAutofocus)) { + elementToAutofocus.focus(); + } + if (elementToAutofocus && elementToAutofocus.id == generatedID) { + elementToAutofocus.removeAttribute("id"); + } + } + } + async function withPreservedFocus(callback) { + const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement); + const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id; + if (restoreFocusTo) { + const elementToFocus = document.getElementById(restoreFocusTo); + if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { + elementToFocus.focus(); + } + } + } + function firstAutofocusableElementInStreams(nodeListOfStreamElements) { + for (const streamElement of nodeListOfStreamElements) { + const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content); + if (elementWithAutofocus) return elementWithAutofocus; + } + return null; + } var StreamObserver = class { + sources = /* @__PURE__ */ new Set(); + #started = false; constructor(delegate) { - this.sources = /* @__PURE__ */ new Set(); - this.started = false; - this.inspectFetchResponse = (event) => { - const response = fetchResponseFromEvent(event); - if (response && fetchResponseIsStream(response)) { - event.preventDefault(); - this.receiveMessageResponse(response); - } - }; - this.receiveMessageEvent = (event) => { - if (this.started && typeof event.data == "string") { - this.receiveMessageHTML(event.data); - } - }; this.delegate = delegate; } start() { - if (!this.started) { - this.started = true; + if (!this.#started) { + this.#started = true; addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); } } stop() { - if (this.started) { - this.started = false; + if (this.#started) { + this.#started = false; removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); } } @@ -3108,6 +4330,18 @@ streamSourceIsConnected(source) { return this.sources.has(source); } + inspectFetchResponse = (event) => { + const response = fetchResponseFromEvent(event); + if (response && fetchResponseIsStream(response)) { + event.preventDefault(); + this.receiveMessageResponse(response); + } + }; + receiveMessageEvent = (event) => { + if (this.#started && typeof event.data == "string") { + this.receiveMessageHTML(event.data); + } + }; async receiveMessageResponse(response) { const html = await response.responseHTML; if (html) { @@ -3119,15 +4353,13 @@ } }; function fetchResponseFromEvent(event) { - var _a; - const fetchResponse = (_a = event.detail) === null || _a === void 0 ? void 0 : _a.fetchResponse; + const fetchResponse = event.detail?.fetchResponse; if (fetchResponse instanceof FetchResponse) { return fetchResponse; } } function fetchResponseIsStream(response) { - var _a; - const contentType = (_a = response.contentType) !== null && _a !== void 0 ? _a : ""; + const contentType = response.contentType ?? ""; return contentType.startsWith(StreamMessage.contentType); } var ErrorRenderer = class extends Renderer { @@ -3184,6 +4416,7 @@ } } async prepareToRender() { + this.#setLanguage(); await this.mergeHead(); } async render() { @@ -3206,12 +4439,24 @@ get newElement() { return this.newSnapshot.element; } + #setLanguage() { + const { documentElement } = this.currentSnapshot; + const { lang } = this.newSnapshot; + if (lang) { + documentElement.setAttribute("lang", lang); + } else { + documentElement.removeAttribute("lang"); + } + } async mergeHead() { const mergedHeadElements = this.mergeProvisionalElements(); const newStylesheetElements = this.copyNewHeadStylesheetElements(); this.copyNewHeadScriptElements(); await mergedHeadElements; await newStylesheetElements; + if (this.willRender) { + this.removeUnusedDynamicStylesheetElements(); + } } async replaceBody() { await this.preservingPermanentElements(async () => { @@ -3235,6 +4480,11 @@ document.head.appendChild(activateScriptElement(element)); } } + removeUnusedDynamicStylesheetElements() { + for (const element of this.unusedDynamicStylesheetElements) { + document.head.removeChild(element); + } + } async mergeProvisionalElements() { const newHeadElements = [...this.newHeadProvisionalElements]; for (const element of this.currentHeadProvisionalElements) { @@ -3287,6 +4537,14 @@ async assignNewBody() { await this.renderElement(this.currentElement, this.newElement); } + get unusedDynamicStylesheetElements() { + return this.oldHeadStylesheetElements.filter((element) => { + return element.getAttribute("data-turbo-track") === "dynamic"; + }); + } + get oldHeadStylesheetElements() { + return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot); + } get newHeadStylesheetElements() { return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot); } @@ -3303,10 +4561,35 @@ return this.newElement.querySelectorAll("script"); } }; + var MorphingPageRenderer = class extends PageRenderer { + static renderElement(currentElement, newElement) { + morphElements(currentElement, newElement, { + callbacks: { + beforeNodeMorphed: (element) => !canRefreshFrame(element) + } + }); + for (const frame of currentElement.querySelectorAll("turbo-frame")) { + if (canRefreshFrame(frame)) frame.reload(); + } + dispatch("turbo:morph", { detail: { currentElement, newElement } }); + } + async preservingPermanentElements(callback) { + return await callback(); + } + get renderMethod() { + return "morph"; + } + get shouldAutofocus() { + return false; + } + }; + function canRefreshFrame(frame) { + return frame instanceof FrameElement && frame.src && frame.refresh === "morph" && !frame.closest("[data-turbo-permanent]"); + } var SnapshotCache = class { + keys = []; + snapshots = {}; constructor(size) { - this.keys = []; - this.snapshots = {}; this.size = size; } has(location2) { @@ -3327,6 +4610,7 @@ clear() { this.snapshots = {}; } + // Private read(location2) { return this.snapshots[toCacheKey(location2)]; } @@ -3336,8 +4620,7 @@ touch(location2) { const key = toCacheKey(location2); const index = this.keys.indexOf(key); - if (index > -1) - this.keys.splice(index, 1); + if (index > -1) this.keys.splice(index, 1); this.keys.unshift(key); this.trim(); } @@ -3348,24 +4631,26 @@ } }; var PageView = class extends View { - constructor() { - super(...arguments); - this.snapshotCache = new SnapshotCache(10); - this.lastRenderedLocation = new URL(location.href); - this.forceReloaded = false; + snapshotCache = new SnapshotCache(10); + lastRenderedLocation = new URL(location.href); + forceReloaded = false; + shouldTransitionTo(newSnapshot) { + return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions; } renderPage(snapshot, isPreview = false, willRender = true, visit2) { - const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender); + const shouldMorphPage = this.isPageRefresh(visit2) && this.snapshot.shouldMorphPage; + const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer; + const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender); if (!renderer.shouldRender) { this.forceReloaded = true; } else { - visit2 === null || visit2 === void 0 ? void 0 : visit2.changeHistory(); + visit2?.changeHistory(); } return this.render(renderer); } renderError(snapshot, visit2) { - visit2 === null || visit2 === void 0 ? void 0 : visit2.changeHistory(); - const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false); + visit2?.changeHistory(); + const renderer = new ErrorRenderer(this.snapshot, snapshot, false); return this.render(renderer); } clearSnapshotCache() { @@ -3384,30 +4669,37 @@ getCachedSnapshotForLocation(location2) { return this.snapshotCache.get(location2); } + isPageRefresh(visit2) { + return !visit2 || this.lastRenderedLocation.pathname === visit2.location.pathname && visit2.action === "replace"; + } + shouldPreserveScrollPosition(visit2) { + return this.isPageRefresh(visit2) && this.snapshot.shouldPreserveScrollPosition; + } get snapshot() { return PageSnapshot.fromElement(this.element); } }; var Preloader = class { - constructor(delegate) { - this.selector = "a[data-turbo-preload]"; + selector = "a[data-turbo-preload]"; + constructor(delegate, snapshotCache) { this.delegate = delegate; - } - get snapshotCache() { - return this.delegate.navigator.view.snapshotCache; + this.snapshotCache = snapshotCache; } start() { if (document.readyState === "loading") { - return document.addEventListener("DOMContentLoaded", () => { - this.preloadOnLoadLinksForView(document.body); - }); + document.addEventListener("DOMContentLoaded", this.#preloadAll); } else { this.preloadOnLoadLinksForView(document.body); } } + stop() { + document.removeEventListener("DOMContentLoaded", this.#preloadAll); + } preloadOnLoadLinksForView(element) { for (const link of element.querySelectorAll(this.selector)) { - this.preloadURL(link); + if (this.delegate.shouldPreloadLink(link)) { + this.preloadURL(link); + } } } async preloadURL(link) { @@ -3415,41 +4707,85 @@ if (this.snapshotCache.has(location2)) { return; } + const fetchRequest = new FetchRequest(this, FetchMethod.get, location2, new URLSearchParams(), link); + await fetchRequest.perform(); + } + // Fetch request delegate + prepareRequest(fetchRequest) { + fetchRequest.headers["X-Sec-Purpose"] = "prefetch"; + } + async requestSucceededWithResponse(fetchRequest, fetchResponse) { try { - const response = await fetch(location2.toString(), { headers: { "VND.PREFETCH": "true", Accept: "text/html" } }); - const responseText = await response.text(); - const snapshot = PageSnapshot.fromHTMLString(responseText); - this.snapshotCache.put(location2, snapshot); + const responseHTML = await fetchResponse.responseHTML; + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + this.snapshotCache.put(fetchRequest.url, snapshot); } catch (_) { } } + requestStarted(fetchRequest) { + } + requestErrored(fetchRequest) { + } + requestFinished(fetchRequest) { + } + requestPreventedHandlingResponse(fetchRequest, fetchResponse) { + } + requestFailedWithResponse(fetchRequest, fetchResponse) { + } + #preloadAll = () => { + this.preloadOnLoadLinksForView(document.body); + }; + }; + var Cache = class { + constructor(session2) { + this.session = session2; + } + clear() { + this.session.clearCache(); + } + resetCacheControl() { + this.#setCacheControl(""); + } + exemptPageFromCache() { + this.#setCacheControl("no-cache"); + } + exemptPageFromPreview() { + this.#setCacheControl("no-preview"); + } + #setCacheControl(value) { + setMetaContent("turbo-cache-control", value); + } }; var Session = class { - constructor() { - this.navigator = new Navigator(this); - this.history = new History(this); - this.preloader = new Preloader(this); - this.view = new PageView(this, document.documentElement); - this.adapter = new BrowserAdapter(this); - this.pageObserver = new PageObserver(this); - this.cacheObserver = new CacheObserver(); - this.linkClickObserver = new LinkClickObserver(this, window); - this.formSubmitObserver = new FormSubmitObserver(this, document); - this.scrollObserver = new ScrollObserver(this); - this.streamObserver = new StreamObserver(this); - this.formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement); - this.frameRedirector = new FrameRedirector(this, document.documentElement); - this.streamMessageRenderer = new StreamMessageRenderer(); - this.drive = true; - this.enabled = true; - this.progressBarDelay = 500; - this.started = false; - this.formMode = "on"; + navigator = new Navigator(this); + history = new History(this); + view = new PageView(this, document.documentElement); + adapter = new BrowserAdapter(this); + pageObserver = new PageObserver(this); + cacheObserver = new CacheObserver(); + linkPrefetchObserver = new LinkPrefetchObserver(this, document); + linkClickObserver = new LinkClickObserver(this, window); + formSubmitObserver = new FormSubmitObserver(this, document); + scrollObserver = new ScrollObserver(this); + streamObserver = new StreamObserver(this); + formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement); + frameRedirector = new FrameRedirector(this, document.documentElement); + streamMessageRenderer = new StreamMessageRenderer(); + cache = new Cache(this); + enabled = true; + started = false; + #pageRefreshDebouncePeriod = 150; + constructor(recentRequests2) { + this.recentRequests = recentRequests2; + this.preloader = new Preloader(this, this.view.snapshotCache); + this.debouncedRefresh = this.refresh; + this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod; } start() { if (!this.started) { this.pageObserver.start(); this.cacheObserver.start(); + this.linkPrefetchObserver.start(); this.formLinkClickObserver.start(); this.linkClickObserver.start(); this.formSubmitObserver.start(); @@ -3469,6 +4805,7 @@ if (this.started) { this.pageObserver.stop(); this.cacheObserver.stop(); + this.linkPrefetchObserver.stop(); this.formLinkClickObserver.stop(); this.linkClickObserver.stop(); this.formSubmitObserver.stop(); @@ -3476,6 +4813,7 @@ this.streamObserver.stop(); this.frameRedirector.stop(); this.history.stop(); + this.preloader.stop(); this.started = false; } } @@ -3485,12 +4823,19 @@ visit(location2, options = {}) { const frameElement = options.frame ? document.getElementById(options.frame) : null; if (frameElement instanceof FrameElement) { + const action = options.action || getVisitAction(frameElement); + frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action); frameElement.src = location2.toString(); - frameElement.loaded; } else { this.navigator.proposeVisit(expandURL(location2), options); } } + refresh(url, requestId) { + const isRecentRequest = requestId && this.recentRequests.has(requestId); + if (!isRecentRequest && !this.navigator.currentVisit) { + this.visit(url, { action: "replace", shouldCacheSnapshot: false }); + } + } connectStreamSource(source) { this.streamObserver.connectStreamSource(source); } @@ -3504,10 +4849,28 @@ this.view.clearSnapshotCache(); } setProgressBarDelay(delay) { + console.warn( + "Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`" + ); this.progressBarDelay = delay; } - setFormMode(mode) { - this.formMode = mode; + set progressBarDelay(delay) { + config.drive.progressBarDelay = delay; + } + get progressBarDelay() { + return config.drive.progressBarDelay; + } + set drive(value) { + config.drive.enabled = value; + } + get drive() { + return config.drive.enabled; + } + set formMode(value) { + config.forms.mode = value; + } + get formMode() { + return config.forms.mode; } get location() { return this.history.location; @@ -3515,11 +4878,33 @@ get restorationIdentifier() { return this.history.restorationIdentifier; } - historyPoppedToLocationWithRestorationIdentifier(location2, restorationIdentifier) { + get pageRefreshDebouncePeriod() { + return this.#pageRefreshDebouncePeriod; + } + set pageRefreshDebouncePeriod(value) { + this.refresh = debounce(this.debouncedRefresh.bind(this), value); + this.#pageRefreshDebouncePeriod = value; + } + // Preloader delegate + shouldPreloadLink(element) { + const isUnsafe = element.hasAttribute("data-turbo-method"); + const isStream = element.hasAttribute("data-turbo-stream"); + const frameTarget = element.getAttribute("data-turbo-frame"); + const frame = frameTarget == "_top" ? null : document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])"); + if (isUnsafe || isStream || frame instanceof FrameElement) { + return false; + } else { + const location2 = new URL(element.href); + return this.elementIsNavigatable(element) && locationIsVisitable(location2, this.snapshot.rootLocation); + } + } + // History delegate + historyPoppedToLocationWithRestorationIdentifierAndDirection(location2, restorationIdentifier, direction) { if (this.enabled) { this.navigator.startVisit(location2, restorationIdentifier, { action: "restore", - historyChanged: true + historyChanged: true, + direction }); } else { this.adapter.pageInvalidated({ @@ -3527,14 +4912,21 @@ }); } } + // Scroll observer delegate scrollPositionChanged(position) { this.history.updateRestorationData({ scrollPosition: position }); } + // Form click observer delegate willSubmitFormLinkToLocation(link, location2) { return this.elementIsNavigatable(link) && locationIsVisitable(location2, this.snapshot.rootLocation); } submittedFormLinkToLocation() { } + // Link hover observer delegate + canPrefetchRequestToLocation(link, location2) { + return this.elementIsNavigatable(link) && locationIsVisitable(location2, this.snapshot.rootLocation); + } + // Link click observer delegate willFollowLinkToLocation(link, location2, event) { return this.elementIsNavigatable(link) && locationIsVisitable(location2, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location2, event); } @@ -3543,6 +4935,7 @@ const acceptsStreamResponse = link.hasAttribute("data-turbo-stream"); this.visit(location2.href, { action, acceptsStreamResponse }); } + // Navigator delegate allowsVisitingLocationWithAction(location2, action) { return this.locationWithActionIsSamePage(location2, action) || this.applicationAllowsVisitingLocation(location2); } @@ -3550,9 +4943,11 @@ extendURLWithDeprecatedProperties(location2); this.adapter.visitProposedToLocation(location2, options); } + // Visit delegate visitStarted(visit2) { if (!visit2.acceptsStreamResponse) { markAsBusy(document.documentElement); + this.view.markVisitDirection(visit2.direction); } extendURLWithDeprecatedProperties(visit2.location); if (!visit2.silent) { @@ -3560,6 +4955,7 @@ } } visitCompleted(visit2) { + this.view.unmarkVisitDirection(); clearBusyState(document.documentElement); this.notifyApplicationAfterPageLoad(visit2.getTimingMetrics()); } @@ -3569,13 +4965,15 @@ visitScrolledToSamePageLocation(oldURL, newURL) { this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL); } - willSubmitForm(form, submitter) { - const action = getAction(form, submitter); - return this.submissionIsNavigatable(form, submitter) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation); + // Form submit observer delegate + willSubmitForm(form, submitter2) { + const action = getAction$1(form, submitter2); + return this.submissionIsNavigatable(form, submitter2) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation); } - formSubmitted(form, submitter) { - this.navigator.submitForm(form, submitter); + formSubmitted(form, submitter2) { + this.navigator.submitForm(form, submitter2); } + // Page observer delegate pageBecameInteractive() { this.view.lastRenderedLocation = this.location; this.notifyApplicationAfterPageLoad(); @@ -3586,26 +4984,30 @@ pageWillUnload() { this.history.relinquishControlOfScrollRestoration(); } + // Stream observer delegate receivedMessageFromStream(message) { this.renderStreamMessage(message); } + // Page view delegate viewWillCacheSnapshot() { - var _a; - if (!((_a = this.navigator.currentVisit) === null || _a === void 0 ? void 0 : _a.silent)) { + if (!this.navigator.currentVisit?.silent) { this.notifyApplicationBeforeCachingSnapshot(); } } allowsImmediateRender({ element }, options) { const event = this.notifyApplicationBeforeRender(element, options); - const { defaultPrevented, detail: { render } } = event; + const { + defaultPrevented, + detail: { render } + } = event; if (this.view.renderer && render) { this.view.renderer.renderElement = render; } return !defaultPrevented; } - viewRenderedSnapshot(_snapshot, _isPreview) { + viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location; - this.notifyApplicationAfterRender(); + this.notifyApplicationAfterRender(renderMethod); } preloadOnLoadLinksForView(element) { this.preloader.preloadOnLoadLinksForView(element); @@ -3613,12 +5015,14 @@ viewInvalidated(reason) { this.adapter.pageInvalidated(reason); } + // Frame element frameLoaded(frame) { this.notifyApplicationAfterFrameLoad(frame); } frameRendered(fetchResponse, frame) { this.notifyApplicationAfterFrameRender(fetchResponse, frame); } + // Application events applicationAllowsFollowingLinkToLocation(link, location2, ev) { const event = this.notifyApplicationAfterClickingLinkToLocation(link, location2, ev); return !event.defaultPrevented; @@ -3648,12 +5052,12 @@ } notifyApplicationBeforeRender(newBody, options) { return dispatch("turbo:before-render", { - detail: Object.assign({ newBody }, options), + detail: { newBody, ...options }, cancelable: true }); } - notifyApplicationAfterRender() { - return dispatch("turbo:render"); + notifyApplicationAfterRender(renderMethod) { + return dispatch("turbo:render", { detail: { renderMethod } }); } notifyApplicationAfterPageLoad(timing = {}) { return dispatch("turbo:load", { @@ -3661,10 +5065,12 @@ }); } notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { - dispatchEvent(new HashChangeEvent("hashchange", { - oldURL: oldURL.toString(), - newURL: newURL.toString() - })); + dispatchEvent( + new HashChangeEvent("hashchange", { + oldURL: oldURL.toString(), + newURL: newURL.toString() + }) + ); } notifyApplicationAfterFrameLoad(frame) { return dispatch("turbo:frame-load", { target: frame }); @@ -3676,12 +5082,13 @@ cancelable: true }); } - submissionIsNavigatable(form, submitter) { - if (this.formMode == "off") { + // Helpers + submissionIsNavigatable(form, submitter2) { + if (config.forms.mode == "off") { return false; } else { - const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; - if (this.formMode == "optin") { + const submitterIsNavigatable = submitter2 ? this.elementIsNavigatable(submitter2) : true; + if (config.forms.mode == "optin") { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null; } else { return submitterIsNavigatable && this.elementIsNavigatable(form); @@ -3691,7 +5098,7 @@ elementIsNavigatable(element) { const container = findClosestRecursively(element, "[data-turbo]"); const withinFrame = findClosestRecursively(element, "turbo-frame"); - if (this.drive || withinFrame) { + if (config.drive.enabled || withinFrame) { if (container) { return container.getAttribute("data-turbo") != "false"; } else { @@ -3705,6 +5112,7 @@ } } } + // Private getActionForLink(link) { return getVisitAction(link) || "advance"; } @@ -3722,63 +5130,8 @@ } } }; - var Cache = class { - constructor(session2) { - this.session = session2; - } - clear() { - this.session.clearCache(); - } - resetCacheControl() { - this.setCacheControl(""); - } - exemptPageFromCache() { - this.setCacheControl("no-cache"); - } - exemptPageFromPreview() { - this.setCacheControl("no-preview"); - } - setCacheControl(value) { - setMetaContent("turbo-cache-control", value); - } - }; - var StreamActions = { - after() { - this.targetElements.forEach((e) => { - var _a; - return (_a = e.parentElement) === null || _a === void 0 ? void 0 : _a.insertBefore(this.templateContent, e.nextSibling); - }); - }, - append() { - this.removeDuplicateTargetChildren(); - this.targetElements.forEach((e) => e.append(this.templateContent)); - }, - before() { - this.targetElements.forEach((e) => { - var _a; - return (_a = e.parentElement) === null || _a === void 0 ? void 0 : _a.insertBefore(this.templateContent, e); - }); - }, - prepend() { - this.removeDuplicateTargetChildren(); - this.targetElements.forEach((e) => e.prepend(this.templateContent)); - }, - remove() { - this.targetElements.forEach((e) => e.remove()); - }, - replace() { - this.targetElements.forEach((e) => e.replaceWith(this.templateContent)); - }, - update() { - this.targetElements.forEach((targetElement) => { - targetElement.innerHTML = ""; - targetElement.append(this.templateContent); - }); - } - }; - var session = new Session(); - var cache = new Cache(session); - var { navigator: navigator$1 } = session; + var session = new Session(recentRequests); + var { cache, navigator: navigator$1 } = session; function start() { session.start(); } @@ -3798,17 +5151,28 @@ session.renderStreamMessage(message); } function clearCache() { - console.warn("Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`"); + console.warn( + "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`" + ); session.clearCache(); } function setProgressBarDelay(delay) { - session.setProgressBarDelay(delay); + console.warn( + "Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`" + ); + config.drive.progressBarDelay = delay; } function setConfirmMethod(confirmMethod) { - FormSubmission.confirmMethod = confirmMethod; + console.warn( + "Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`" + ); + config.forms.confirm = confirmMethod; } function setFormMode(mode) { - session.setFormMode(mode); + console.warn( + "Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`" + ); + config.forms.mode = mode; } var Turbo = /* @__PURE__ */ Object.freeze({ __proto__: null, @@ -3818,6 +5182,8 @@ PageRenderer, PageSnapshot, FrameRenderer, + fetch: fetchWithTurboHeaders, + config, start, registerAdapter, visit, @@ -3827,29 +5193,21 @@ clearCache, setProgressBarDelay, setConfirmMethod, - setFormMode, - StreamActions + setFormMode }); var TurboFrameMissingError = class extends Error { }; var FrameController = class { + fetchResponseLoaded = (_fetchResponse) => Promise.resolve(); + #currentFetchRequest = null; + #resolveVisitPromise = () => { + }; + #connected = false; + #hasBeenLoaded = false; + #ignoredAttributes = /* @__PURE__ */ new Set(); + #shouldMorphFrame = false; + action = null; constructor(element) { - this.fetchResponseLoaded = (_fetchResponse) => { - }; - this.currentFetchRequest = null; - this.resolveVisitPromise = () => { - }; - this.connected = false; - this.hasBeenLoaded = false; - this.ignoredAttributes = /* @__PURE__ */ new Set(); - this.action = null; - this.visitCachedSnapshot = ({ element: element2 }) => { - const frame = element2.querySelector("#" + this.element.id); - if (frame && this.previousFrameElement) { - frame.replaceChildren(...this.previousFrameElement.children); - } - delete this.previousFrameElement; - }; this.element = element; this.view = new FrameView(this, this.element); this.appearanceObserver = new AppearanceObserver(this, this.element); @@ -3858,13 +5216,14 @@ this.restorationIdentifier = uuid(); this.formSubmitObserver = new FormSubmitObserver(this, this.element); } + // Frame delegate connect() { - if (!this.connected) { - this.connected = true; + if (!this.#connected) { + this.#connected = true; if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start(); } else { - this.loadSourceURL(); + this.#loadSourceURL(); } this.formLinkClickObserver.start(); this.linkInterceptor.start(); @@ -3872,8 +5231,8 @@ } } disconnect() { - if (this.connected) { - this.connected = false; + if (this.#connected) { + this.#connected = false; this.appearanceObserver.stop(); this.formLinkClickObserver.stop(); this.linkInterceptor.stop(); @@ -3882,47 +5241,40 @@ } disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { - this.loadSourceURL(); + this.#loadSourceURL(); } } sourceURLChanged() { - if (this.isIgnoringChangesTo("src")) - return; + if (this.#isIgnoringChangesTo("src")) return; if (this.element.isConnected) { this.complete = false; } - if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { - this.loadSourceURL(); + if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { + this.#loadSourceURL(); } } sourceURLReloaded() { - const { src } = this.element; - this.ignoringChangesToAttribute("complete", () => { - this.element.removeAttribute("complete"); - }); + const { refresh, src } = this.element; + this.#shouldMorphFrame = src && refresh === "morph"; + this.element.removeAttribute("complete"); this.element.src = null; this.element.src = src; return this.element.loaded; } - completeChanged() { - if (this.isIgnoringChangesTo("complete")) - return; - this.loadSourceURL(); - } loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start(); } else { this.appearanceObserver.stop(); - this.loadSourceURL(); + this.#loadSourceURL(); } } - async loadSourceURL() { + async #loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { - this.element.loaded = this.visit(expandURL(this.sourceURL)); + this.element.loaded = this.#visit(expandURL(this.sourceURL)); this.appearanceObserver.stop(); await this.element.loaded; - this.hasBeenLoaded = true; + this.#hasBeenLoaded = true; } } async loadResponse(fetchResponse) { @@ -3935,50 +5287,53 @@ const document2 = parseHTMLDocument(html); const pageSnapshot = PageSnapshot.fromDocument(document2); if (pageSnapshot.isVisitable) { - await this.loadFrameResponse(fetchResponse, document2); + await this.#loadFrameResponse(fetchResponse, document2); } else { - await this.handleUnvisitableFrameResponse(fetchResponse); + await this.#handleUnvisitableFrameResponse(fetchResponse); } } } finally { - this.fetchResponseLoaded = () => { - }; + this.#shouldMorphFrame = false; + this.fetchResponseLoaded = () => Promise.resolve(); } } + // Appearance observer delegate elementAppearedInViewport(element) { - this.proposeVisitIfNavigatedWithAction(element, element); - this.loadSourceURL(); + this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)); + this.#loadSourceURL(); } + // Form link click observer delegate willSubmitFormLinkToLocation(link) { - return this.shouldInterceptNavigation(link); + return this.#shouldInterceptNavigation(link); } submittedFormLinkToLocation(link, _location, form) { - const frame = this.findFrameElement(link); - if (frame) - form.setAttribute("data-turbo-frame", frame.id); + const frame = this.#findFrameElement(link); + if (frame) form.setAttribute("data-turbo-frame", frame.id); } + // Link interceptor delegate shouldInterceptLinkClick(element, _location, _event) { - return this.shouldInterceptNavigation(element); + return this.#shouldInterceptNavigation(element); } linkClickIntercepted(element, location2) { - this.navigateFrame(element, location2); + this.#navigateFrame(element, location2); } - willSubmitForm(element, submitter) { - return element.closest("turbo-frame") == this.element && this.shouldInterceptNavigation(element, submitter); + // Form submit observer delegate + willSubmitForm(element, submitter2) { + return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter2); } - formSubmitted(element, submitter) { + formSubmitted(element, submitter2) { if (this.formSubmission) { this.formSubmission.stop(); } - this.formSubmission = new FormSubmission(this, element, submitter); + this.formSubmission = new FormSubmission(this, element, submitter2); const { fetchRequest } = this.formSubmission; this.prepareRequest(fetchRequest); this.formSubmission.start(); } + // Fetch request delegate prepareRequest(request) { - var _a; request.headers["Turbo-Frame"] = this.id; - if ((_a = this.currentNavigationElement) === null || _a === void 0 ? void 0 : _a.hasAttribute("data-turbo-stream")) { + if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { request.acceptResponseType(StreamMessage.contentType); } } @@ -3986,29 +5341,30 @@ markAsBusy(this.element); } requestPreventedHandlingResponse(_request, _response) { - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } async requestSucceededWithResponse(request, response) { await this.loadResponse(response); - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } async requestFailedWithResponse(request, response) { await this.loadResponse(response); - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } requestErrored(request, error2) { console.error(error2); - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } requestFinished(_request) { clearBusyState(this.element); } + // Form submission delegate formSubmissionStarted({ formElement }) { - markAsBusy(formElement, this.findFrameElement(formElement)); + markAsBusy(formElement, this.#findFrameElement(formElement)); } formSubmissionSucceededWithResponse(formSubmission, response) { - const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter); - frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter); + const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter); + frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)); frame.delegate.loadResponse(response); if (!formSubmission.isSafe) { session.clearCache(); @@ -4022,78 +5378,90 @@ console.error(error2); } formSubmissionFinished({ formElement }) { - clearBusyState(formElement, this.findFrameElement(formElement)); + clearBusyState(formElement, this.#findFrameElement(formElement)); } + // View delegate allowsImmediateRender({ element: newFrame }, options) { const event = dispatch("turbo:before-frame-render", { target: this.element, - detail: Object.assign({ newFrame }, options), + detail: { newFrame, ...options }, cancelable: true }); - const { defaultPrevented, detail: { render } } = event; + const { + defaultPrevented, + detail: { render } + } = event; if (this.view.renderer && render) { this.view.renderer.renderElement = render; } return !defaultPrevented; } - viewRenderedSnapshot(_snapshot, _isPreview) { + viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) { } preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element); } viewInvalidated() { } + // Frame renderer delegate willRenderFrame(currentElement, _newElement) { this.previousFrameElement = currentElement.cloneNode(true); } - async loadFrameResponse(fetchResponse, document2) { + visitCachedSnapshot = ({ element }) => { + const frame = element.querySelector("#" + this.element.id); + if (frame && this.previousFrameElement) { + frame.replaceChildren(...this.previousFrameElement.children); + } + delete this.previousFrameElement; + }; + // Private + async #loadFrameResponse(fetchResponse, document2) { const newFrameElement = await this.extractForeignFrameElement(document2.body); + const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer; if (newFrameElement) { const snapshot = new Snapshot(newFrameElement); - const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false); - if (this.view.renderPromise) - await this.view.renderPromise; + const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false); + if (this.view.renderPromise) await this.view.renderPromise; this.changeHistory(); await this.view.render(renderer); this.complete = true; session.frameRendered(fetchResponse, this.element); session.frameLoaded(this.element); - this.fetchResponseLoaded(fetchResponse); - } else if (this.willHandleFrameMissingFromResponse(fetchResponse)) { - this.handleFrameMissingFromResponse(fetchResponse); + await this.fetchResponseLoaded(fetchResponse); + } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { + this.#handleFrameMissingFromResponse(fetchResponse); } } - async visit(url) { - var _a; + async #visit(url) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element); - (_a = this.currentFetchRequest) === null || _a === void 0 ? void 0 : _a.cancel(); - this.currentFetchRequest = request; + this.#currentFetchRequest?.cancel(); + this.#currentFetchRequest = request; return new Promise((resolve) => { - this.resolveVisitPromise = () => { - this.resolveVisitPromise = () => { + this.#resolveVisitPromise = () => { + this.#resolveVisitPromise = () => { }; - this.currentFetchRequest = null; + this.#currentFetchRequest = null; resolve(); }; request.perform(); }); } - navigateFrame(element, url, submitter) { - const frame = this.findFrameElement(element, submitter); - frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter); - this.withCurrentNavigationElement(element, () => { + #navigateFrame(element, url, submitter2) { + const frame = this.#findFrameElement(element, submitter2); + frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter2, element, frame)); + this.#withCurrentNavigationElement(element, () => { frame.src = url; }); } - proposeVisitIfNavigatedWithAction(frame, element, submitter) { - this.action = getVisitAction(submitter, element, frame); + proposeVisitIfNavigatedWithAction(frame, action = null) { + this.action = action; if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone(); const { visitCachedSnapshot } = frame.delegate; - frame.delegate.fetchResponseLoaded = (fetchResponse) => { + frame.delegate.fetchResponseLoaded = async (fetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse; - const responseHTML = frame.ownerDocument.documentElement.outerHTML; + const responseHTML = await fetchResponse.responseHTML; const response = { statusCode, redirected, responseHTML }; const options = { response, @@ -4103,8 +5471,7 @@ restorationIdentifier: this.restorationIdentifier, snapshot: pageSnapshot }; - if (this.action) - options.action = this.action; + if (this.action) options.action = this.action; session.visit(frame.src, options); } }; @@ -4116,16 +5483,18 @@ session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier); } } - async handleUnvisitableFrameResponse(fetchResponse) { - console.warn(`The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.`); - await this.visitResponse(fetchResponse.response); + async #handleUnvisitableFrameResponse(fetchResponse) { + console.warn( + `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` + ); + await this.#visitResponse(fetchResponse.response); } - willHandleFrameMissingFromResponse(fetchResponse) { + #willHandleFrameMissingFromResponse(fetchResponse) { this.element.setAttribute("complete", ""); const response = fetchResponse.response; - const visit2 = async (url, options = {}) => { + const visit2 = async (url, options) => { if (url instanceof Response) { - this.visitResponse(url); + this.#visitResponse(url); } else { session.visit(url, options); } @@ -4137,24 +5506,23 @@ }); return !event.defaultPrevented; } - handleFrameMissingFromResponse(fetchResponse) { + #handleFrameMissingFromResponse(fetchResponse) { this.view.missing(); - this.throwFrameMissingError(fetchResponse); + this.#throwFrameMissingError(fetchResponse); } - throwFrameMissingError(fetchResponse) { + #throwFrameMissingError(fetchResponse) { const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`; throw new TurboFrameMissingError(message); } - async visitResponse(response) { + async #visitResponse(response) { const wrapped = new FetchResponse(response); const responseHTML = await wrapped.responseHTML; const { location: location2, redirected, statusCode } = wrapped; return session.visit(location2, { response: { redirected, statusCode, responseHTML } }); } - findFrameElement(element, submitter) { - var _a; - const id2 = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - return (_a = getFrameElementById(id2)) !== null && _a !== void 0 ? _a : this.element; + #findFrameElement(element, submitter2) { + const id2 = getAttribute("data-turbo-frame", submitter2, element) || this.element.getAttribute("target"); + return getFrameElementById(id2) ?? this.element; } async extractForeignFrameElement(container) { let element; @@ -4175,13 +5543,13 @@ } return null; } - formActionIsVisitable(form, submitter) { - const action = getAction(form, submitter); + #formActionIsVisitable(form, submitter2) { + const action = getAction$1(form, submitter2); return locationIsVisitable(expandURL(action), this.rootLocation); } - shouldInterceptNavigation(element, submitter) { - const id2 = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - if (element instanceof HTMLFormElement && !this.formActionIsVisitable(element, submitter)) { + #shouldInterceptNavigation(element, submitter2) { + const id2 = getAttribute("data-turbo-frame", submitter2, element) || this.element.getAttribute("target"); + if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter2)) { return false; } if (!this.enabled || id2 == "_top") { @@ -4196,11 +5564,12 @@ if (!session.elementIsNavigatable(element)) { return false; } - if (submitter && !session.elementIsNavigatable(submitter)) { + if (submitter2 && !session.elementIsNavigatable(submitter2)) { return false; } return true; } + // Computed properties get id() { return this.element.id; } @@ -4213,46 +5582,43 @@ } } set sourceURL(sourceURL) { - this.ignoringChangesToAttribute("src", () => { - this.element.src = sourceURL !== null && sourceURL !== void 0 ? sourceURL : null; + this.#ignoringChangesToAttribute("src", () => { + this.element.src = sourceURL ?? null; }); } get loadingStyle() { return this.element.loading; } get isLoading() { - return this.formSubmission !== void 0 || this.resolveVisitPromise() !== void 0; + return this.formSubmission !== void 0 || this.#resolveVisitPromise() !== void 0; } get complete() { return this.element.hasAttribute("complete"); } set complete(value) { - this.ignoringChangesToAttribute("complete", () => { - if (value) { - this.element.setAttribute("complete", ""); - } else { - this.element.removeAttribute("complete"); - } - }); + if (value) { + this.element.setAttribute("complete", ""); + } else { + this.element.removeAttribute("complete"); + } } get isActive() { - return this.element.isActive && this.connected; + return this.element.isActive && this.#connected; } get rootLocation() { - var _a; const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); - const root = (_a = meta === null || meta === void 0 ? void 0 : meta.content) !== null && _a !== void 0 ? _a : "/"; + const root = meta?.content ?? "/"; return expandURL(root); } - isIgnoringChangesTo(attributeName) { - return this.ignoredAttributes.has(attributeName); + #isIgnoringChangesTo(attributeName) { + return this.#ignoredAttributes.has(attributeName); } - ignoringChangesToAttribute(attributeName, callback) { - this.ignoredAttributes.add(attributeName); + #ignoringChangesToAttribute(attributeName, callback) { + this.#ignoredAttributes.add(attributeName); callback(); - this.ignoredAttributes.delete(attributeName); + this.#ignoredAttributes.delete(attributeName); } - withCurrentNavigationElement(element, callback) { + #withCurrentNavigationElement(element, callback) { this.currentNavigationElement = element; callback(); delete this.currentNavigationElement; @@ -4282,6 +5648,49 @@ } } } + var StreamActions = { + after() { + this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); + }, + append() { + this.removeDuplicateTargetChildren(); + this.targetElements.forEach((e) => e.append(this.templateContent)); + }, + before() { + this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); + }, + prepend() { + this.removeDuplicateTargetChildren(); + this.targetElements.forEach((e) => e.prepend(this.templateContent)); + }, + remove() { + this.targetElements.forEach((e) => e.remove()); + }, + replace() { + const method = this.getAttribute("method"); + this.targetElements.forEach((targetElement) => { + if (method === "morph") { + morphElements(targetElement, this.templateContent); + } else { + targetElement.replaceWith(this.templateContent); + } + }); + }, + update() { + const method = this.getAttribute("method"); + this.targetElements.forEach((targetElement) => { + if (method === "morph") { + morphChildren(targetElement, this.templateContent); + } else { + targetElement.innerHTML = ""; + targetElement.append(this.templateContent); + } + }); + }, + refresh() { + session.refresh(this.baseURI, this.requestId); + } + }; var StreamElement = class _StreamElement extends HTMLElement { static async renderElement(newElement) { await newElement.performAction(); @@ -4296,11 +5705,10 @@ } } async render() { - var _a; - return (_a = this.renderPromise) !== null && _a !== void 0 ? _a : this.renderPromise = (async () => { + return this.renderPromise ??= (async () => { const event = this.beforeRenderEvent; if (this.dispatchEvent(event)) { - await nextAnimationFrame(); + await nextRepaint(); await event.detail.render(this); } })(); @@ -4308,40 +5716,57 @@ disconnect() { try { this.remove(); - } catch (_a) { + } catch { } } + /** + * Removes duplicate children (by ID) + */ removeDuplicateTargetChildren() { this.duplicateChildren.forEach((c) => c.remove()); } + /** + * Gets the list of duplicate children (i.e. those with the same ID) + */ get duplicateChildren() { - var _a; const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id); - const newChildrenIds = [...((_a = this.templateContent) === null || _a === void 0 ? void 0 : _a.children) || []].filter((c) => !!c.id).map((c) => c.id); + const newChildrenIds = [...this.templateContent?.children || []].filter((c) => !!c.id).map((c) => c.id); return existingChildren.filter((c) => newChildrenIds.includes(c.id)); } + /** + * Gets the action function to be performed. + */ get performAction() { if (this.action) { const actionFunction = StreamActions[this.action]; if (actionFunction) { return actionFunction; } - this.raise("unknown action"); + this.#raise("unknown action"); } - this.raise("action attribute is missing"); + this.#raise("action attribute is missing"); } + /** + * Gets the target elements which the template will be rendered to. + */ get targetElements() { if (this.target) { return this.targetElementsById; } else if (this.targets) { return this.targetElementsByQuery; } else { - this.raise("target or targets attribute is missing"); + this.#raise("target or targets attribute is missing"); } } + /** + * Gets the contents of the main `