diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index aa9e74e7e..f18b68618 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -1,14 +1,9 @@ on: workflow_call: inputs: - platforms: - required: true - type: string cache: type: boolean default: true - use_native_arm64_builder: - type: boolean push_to_images: type: string version_prerelease: @@ -22,42 +17,36 @@ on: labels: type: string +# This builds multiple images with one runner each, allowing us to build for multiple architectures +# using Github's runners. +# The two-step process is adapted form: +# https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners jobs: + # Build each (amd64 and arm64) image separately build-image: - runs-on: ubuntu-latest + runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v2 - if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder + - name: Prepare + env: + PUSH_TO_IMAGES: ${{ inputs.push_to_images }} + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + # Transform multi-line variable into comma-separated variable + image_names=${PUSH_TO_IMAGES//$'\n'/,} + echo "IMAGE_NAMES=${image_names%,}" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v2 id: buildx - if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }} - - - name: Start a local Docker Builder - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - run: | - docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234 - - - uses: docker/setup-buildx-action@v2 - id: buildx-native - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - with: - driver: remote - endpoint: tcp://localhost:1234 - platforms: linux/amd64 - append: | - - endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865 - platforms: linux/arm64 - name: mastodon-docker-builder-arm64-01 - driver-opts: - - servername=mastodon-docker-builder-arm64-01 - env: - BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }} - BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }} - BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }} - name: Log in to Docker Hub if: contains(inputs.push_to_images, 'tootsuite') @@ -74,8 +63,88 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v4 + - name: Docker meta id: meta + uses: docker/metadata-action@v5 + if: ${{ inputs.push_to_images != '' }} + with: + images: ${{ inputs.push_to_images }} + flavor: ${{ inputs.flavor }} + labels: ${{ inputs.labels }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + build-args: | + MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} + MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} + SOURCE_COMMIT=${{ github.sha }} + platforms: ${{ matrix.platform }} + provenance: false + push: ${{ inputs.push_to_images != '' }} + cache-from: ${{ inputs.cache && 'type=gha' || '' }} + cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} + outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }} + + - name: Export digest + if: ${{ inputs.push_to_images != '' }} + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + if: ${{ inputs.push_to_images != '' }} + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Then merge the docker images into a single one + merge-images: + if: ${{ inputs.push_to_images != '' }} + runs-on: ubuntu-24.04 + needs: + - build-image + + env: + PUSH_TO_IMAGES: ${{ inputs.push_to_images }} + + steps: + - uses: actions/checkout@v4 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Log in to Docker Hub + if: contains(inputs.push_to_images, 'tootsuite') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the GitHub Container registry + if: contains(inputs.push_to_images, 'ghcr.io') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 if: ${{ inputs.push_to_images != '' }} with: images: ${{ inputs.push_to_images }} @@ -83,17 +152,14 @@ jobs: tags: ${{ inputs.tags }} labels: ${{ inputs.labels }} - - uses: docker/build-push-action@v4 - with: - context: . - build-args: | - MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} - MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} - platforms: ${{ inputs.platforms }} - provenance: false - builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} - push: ${{ inputs.push_to_images != '' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: ${{ inputs.cache && 'type=gha' || '' }} - cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + echo "$PUSH_TO_IMAGES" | xargs -I{} \ + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '{}@sha256:%s ' *) + + - name: Inspect image + run: | + echo "$PUSH_TO_IMAGES" | xargs -i{} \ + docker buildx imagetools inspect {}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml index aa1f916af..f0dc068b9 100644 --- a/.github/workflows/build-nightly.yml +++ b/.github/workflows/build-nightly.yml @@ -24,8 +24,6 @@ jobs: needs: compute-suffix uses: ./.github/workflows/build-container-image.yml with: - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index 1f647e2a1..8ed8493c7 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -29,8 +29,6 @@ jobs: needs: compute-suffix uses: ./.github/workflows/build-container-image.yml with: - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | ghcr.io/mastodon/mastodon version_metadata: ${{ needs.compute-suffix.outputs.metadata }} diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index b1b6744a8..71cbc8495 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -12,8 +12,6 @@ jobs: build-image: uses: ./.github/workflows/build-container-image.yml with: - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | tootsuite/mastodon ghcr.io/mastodon/mastodon diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml index 778e34177..0112f4106 100644 --- a/.github/workflows/test-image-build.yml +++ b/.github/workflows/test-image-build.yml @@ -17,5 +17,3 @@ jobs: cancel-in-progress: true uses: ./.github/workflows/build-container-image.yml - with: - platforms: linux/amd64 # Testing only on native platform so it is performant diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 343dc36ca..e07a0b605 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -58,7 +58,7 @@ jobs: run: |- ./bin/rails assets:precompile - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: matrix.mode == 'test' with: path: |- @@ -118,9 +118,9 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' - '.ruby-version' + - '3.3' ci_job: - 1 - 2 @@ -129,7 +129,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './public' name: ${{ github.sha }} @@ -197,14 +197,14 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' - '.ruby-version' + - '3.3' steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './public' name: ${{ github.sha }} @@ -238,14 +238,14 @@ jobs: - run: bundle exec rake spec:system - name: Archive logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: e2e-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: e2e-screenshots @@ -310,14 +310,14 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' - '.ruby-version' + - '3.3' steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: path: './public' name: ${{ github.sha }} @@ -351,14 +351,14 @@ jobs: - run: bundle exec rake spec:search - name: Archive logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: test-search-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: test-search-screenshots diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a830ad7..5fd009f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ All notable changes to this project will be documented in this file. +## [4.2.20] - 2025-04-02 + +### Add + +- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire) +- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire) + +### Changed + +- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire) +- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski) +- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire) + +### Fixed + +- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire) + +## [4.2.19] - 2025-03-13 + +### Security + +- Update dependency `omniauth-saml` +- Update dependency `rack` + +### Fixed + +- Fix Stoplight errors when using `REDIS_NAMESPACE` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/34126)) + +## [4.2.18] - 2025-03-10 + +### Changed + +- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire) + +### Fixed + +- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap) +- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire) + +## [4.2.17] - 2025-02-27 + +### Security + +- Update dependencies + +### Removed + +- Remove support for Ruby 3.0 + +## [4.2.16] - 2025-02-27 + +### Security + +- Update dependencies +- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf)) +- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h)) +- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825)) + +### Fixed + +- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire) +- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire) +- Fix polls not being validated on edition (#33755 by @ClearlyClaire) +- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski) +- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan) + ## [4.2.15] - 2025-01-16 ### Security diff --git a/Dockerfile b/Dockerfile index c9bc33b31..1c7324ca8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,6 +57,9 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV DEBIAN_FRONTEND="noninteractive" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" +# Add backport repository for some specific packages where we need the latest version +RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list + # Ignoring these here since we don't want to pin any versions and the Debian image removes apt-get content after use # hadolint ignore=DL3008,DL3009 RUN apt-get update && \ @@ -74,6 +77,7 @@ RUN apt-get update && \ libicu72 \ libidn12 \ libyaml-0-2 \ + libheif1/bookworm-backports \ file \ ca-certificates \ tzdata \ diff --git a/Gemfile b/Gemfile index 7f7da8c57..ca48764dd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 3.0.0' +ruby '>= 3.1.0' gem 'puma', '~> 6.3' gem 'rails', '~> 7.0' @@ -60,7 +60,7 @@ gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' -gem 'nokogiri', '~> 1.15' +gem 'nokogiri', '~> 1.17' gem 'nsa' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' diff --git a/Gemfile.lock b/Gemfile.lock index 2008d6390..238e1bda9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -455,7 +455,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.3.7) + net-imap (0.3.8) date net-protocol net-ldap (0.18.0) @@ -469,7 +469,7 @@ GEM net-protocol net-ssh (7.1.0) nio4r (2.7.4) - nokogiri (1.16.8) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) nsa (0.3.0) @@ -478,16 +478,16 @@ GEM sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) oj (3.16.1) - omniauth (2.1.2) + omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.1.2) + omniauth-saml (2.1.3) omniauth (~> 2.1) - ruby-saml (~> 1.17) + ruby-saml (~> 1.18) omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) openid_connect (~> 1.1) @@ -533,7 +533,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -666,7 +666,7 @@ GEM rubocop-factory_bot (~> 2.22) ruby-prof (1.6.3) ruby-progressbar (1.13.0) - ruby-saml (1.17.0) + ruby-saml (1.18.0) nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) @@ -769,7 +769,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (2.4.2) - uri (0.12.2) + uri (0.12.4) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -878,7 +878,7 @@ DEPENDENCIES mime-types (~> 3.5.0) net-http (~> 0.3.2) net-ldap (~> 0.18) - nokogiri (~> 1.15) + nokogiri (~> 1.17) nsa oj (~> 3.14) omniauth (~> 2.0) diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 76ba75824..12187078d 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController @account = current_account UpdateAccountService.new.call(@account, account_params, raise_error: true) current_user.update(user_params) if user_params - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index c91234e08..16f05c47e 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -15,16 +15,40 @@ class Api::V1::Instances::DomainBlocksController < Api::BaseController cache_if_unauthenticated! end - render json: @domain_blocks, each_serializer: REST::DomainBlockSerializer, with_comment: (Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)) + render json: @domain_blocks, each_serializer: REST::DomainBlockSerializer, with_comment: show_rationale_in_response? end private def require_enabled_api! - head 404 unless Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) + head 404 unless api_enabled? + end + + def api_enabled? + show_domain_blocks_for_all? || show_domain_blocks_to_user? + end + + def show_domain_blocks_for_all? + Setting.show_domain_blocks == 'all' + end + + def show_domain_blocks_to_user? + Setting.show_domain_blocks == 'users' && user_signed_in? && current_user.functional_or_moved? end def set_domain_blocks @domain_blocks = DomainBlock.with_user_facing_limitations.by_severity end + + def show_rationale_in_response? + always_show_rationale? || show_rationale_for_user? + end + + def always_show_rationale? + Setting.show_domain_blocks_rationale == 'all' + end + + def show_rationale_for_user? + Setting.show_domain_blocks_rationale == 'users' && user_signed_in? && current_user.functional_or_moved? + end end diff --git a/app/controllers/api/v1/profile/avatars_controller.rb b/app/controllers/api/v1/profile/avatars_controller.rb index bc4d01a59..e6c954ed6 100644 --- a/app/controllers/api/v1/profile/avatars_controller.rb +++ b/app/controllers/api/v1/profile/avatars_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v1/profile/headers_controller.rb b/app/controllers/api/v1/profile/headers_controller.rb index 9f4daa2f7..4472a01b0 100644 --- a/app/controllers/api/v1/profile/headers_controller.rb +++ b/app/controllers/api/v1/profile/headers_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb index db23fefbb..c9c1d9d99 100644 --- a/app/controllers/backups_controller.rb +++ b/app/controllers/backups_controller.rb @@ -8,13 +8,15 @@ class BackupsController < ApplicationController before_action :authenticate_user! before_action :set_backup + BACKUP_LINK_TIMEOUT = 1.hour.freeze + def download case Paperclip::Attachment.default_options[:storage] when :s3, :azure - redirect_to @backup.dump.expiring_url(10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true when :fog if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? - redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true else redirect_to full_asset_url(@backup.dump.url), allow_other_host: true end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 92f1eb5a1..de8fe82e5 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -154,7 +154,7 @@ module SignatureVerification def verify_signature_strength! raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') - raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') + raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') end @@ -192,14 +192,14 @@ module SignatureVerification def build_signed_string(include_query_string: true) signed_headers.map do |signed_header| case signed_header - when Request::REQUEST_TARGET + when HttpSignatureDraft::REQUEST_TARGET if include_query_string - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" else # Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header. # Therefore, temporarily support such incorrect signatures for compatibility. # TODO: remove eventually some time after release of the fixed version - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" end when '(created)' raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb index 58a432530..7e61e6d58 100644 --- a/app/controllers/settings/pictures_controller.rb +++ b/app/controllers/settings/pictures_controller.rb @@ -8,7 +8,7 @@ module Settings def destroy if valid_picture? if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 else redirect_to settings_profile_path diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb index 1102c89fa..6c4836ec5 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params.except(:settings)) current_user.update!(settings_attributes: account_params[:settings]) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 8ae69b7fe..2d6413275 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else @account.build_fields diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index c11ef458c..111f33436 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -112,7 +112,7 @@ const emojifyTextNode = (node, customEmojis) => { }; const emojifyNode = (node, customEmojis) => { - for (const child of node.childNodes) { + for (const child of Array.from(node.childNodes)) { switch(child.nodeType) { case Node.TEXT_NODE: emojifyTextNode(child, customEmojis); diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index e6b2509f6..ad367ea7b 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -254,12 +254,26 @@ const expiresInFromExpiresAt = expires_at => { const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); - return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); - } else { - return suggestions; + suggestions = suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); } + + // Prefer capitalization from personal history, unless personal history is all lower-case + const fixSuggestionCapitalization = (suggestion) => { + if (suggestion.type !== 'hashtag') + return suggestion; + + const tagFromHistory = tagHistory.find((tag) => tag.localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) === 0); + + if (!tagFromHistory || tagFromHistory.toLowerCase() === tagFromHistory) + return suggestion; + + return { ...suggestion, name: tagFromHistory }; + }; + + return suggestions.map(fixSuggestionCapitalization); }; const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb index 481e25439..4bf5c229a 100644 --- a/app/lib/account_reach_finder.rb +++ b/app/lib/account_reach_finder.rb @@ -1,12 +1,16 @@ # frozen_string_literal: true class AccountReachFinder + RECENT_LIMIT = 2_000 + STATUS_LIMIT = 200 + STATUS_SINCE = 2.days + def initialize(account) @account = account end def inboxes - (followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + relay_inboxes).uniq + (followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + recently_followed_inboxes + recently_requested_inboxes + relay_inboxes).uniq end private @@ -20,13 +24,46 @@ class AccountReachFinder end def recently_mentioned_inboxes - cutoff_id = Mastodon::Snowflake.id_at(2.days.ago, with_random: false) - recent_statuses = @account.statuses.recent.where(id: cutoff_id...).limit(200) + Account + .joins(:mentions) + .where(mentions: { status: recent_statuses }) + .inboxes + .take(RECENT_LIMIT) + end - Account.joins(:mentions).where(mentions: { status: recent_statuses }).inboxes.take(2000) + def recently_followed_inboxes + @account + .following + .where(follows: { created_at: recent_date_cutoff... }) + .inboxes + .take(RECENT_LIMIT) + end + + def recently_requested_inboxes + Account + .where(id: @account.follow_requests.where({ created_at: recent_date_cutoff... }).select(:target_account_id)) + .inboxes + .take(RECENT_LIMIT) end def relay_inboxes Relay.enabled.pluck(:inbox_url) end + + def oldest_status_id + Mastodon::Snowflake + .id_at(recent_date_cutoff, with_random: false) + end + + def recent_date_cutoff + @account.suspended? && @account.suspension_origin_local? ? @account.suspended_at - STATUS_SINCE : STATUS_SINCE.ago + end + + def recent_statuses + @account + .statuses + .recent + .where(id: oldest_status_id...) + .limit(STATUS_LIMIT) + end end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index d93497521..459278c41 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -85,6 +85,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ApplicationRecord.transaction do @status = Status.create!(@params) attach_tags(@status) + attach_mentions(@status) end resolve_thread(@status) @@ -188,6 +189,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity # not a big deal Trends.tags.register(status) + # Update featured tags + return if @tags.empty? || !status.distributable? + + @account.featured_tags.where(tag_id: @tags.pluck(:id)).find_each do |featured_tag| + featured_tag.increment(status.created_at) + end + end + + def attach_mentions(status) @mentions.each do |mention| mention.status = status mention.save diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index f5aa3c9a2..dc8ee4f09 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -32,24 +32,31 @@ class FeedManager "feed:#{type}:#{id}:#{subtype}" end + # The filter result of the status to a particular feed + # @param [Symbol] timeline_type + # @param [Status] status + # @param [Account|List] receiver + # @return [void|Symbol] nil, :filter, or :skip_home + def filter(timeline_type, status, receiver) + case timeline_type + when :home + filter_from_home(status, receiver.id, build_crutches(receiver.id, [status]), :home) + when :list + (filter_from_list?(status, receiver) ? :filter : nil) || filter_from_home(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list) + when :mentions + filter_from_mentions?(status, receiver.id) ? :filter : nil + when :tags + filter_from_tags?(status, receiver.id, build_crutches(receiver.id, [status])) ? :filter : nil + end + end + # Check if the status should not be added to a feed # @param [Symbol] timeline_type # @param [Status] status # @param [Account|List] receiver # @return [Boolean] def filter?(timeline_type, status, receiver) - case timeline_type - when :home - filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), :home) - when :list - filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list) - when :mentions - filter_from_mentions?(status, receiver.id) - when :tags - filter_from_tags?(status, receiver.id, build_crutches(receiver.id, [status])) - else - false - end + !!filter(timeline_type, status, receiver) end # Add a status to a home feed and send a streaming API update @@ -125,7 +132,7 @@ class FeedManager crutches = build_crutches(into_account.id, statuses) statuses.each do |status| - next if filter_from_home?(status, into_account.id, crutches) + next if filter_from_home(status, into_account.id, crutches) add_to_feed(:home, into_account.id, status, aggregate_reblogs: aggregate) end @@ -153,7 +160,7 @@ class FeedManager crutches = build_crutches(list.account_id, statuses) statuses.each do |status| - next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list) + next if filter_from_home(status, list.account_id, crutches) || filter_from_list?(status, list) add_to_feed(:list, list.id, status, aggregate_reblogs: aggregate) end @@ -285,7 +292,7 @@ class FeedManager crutches = build_crutches(account.id, statuses) statuses.each do |status| - next if filter_from_home?(status, account.id, crutches) + next if filter_from_home(status, account.id, crutches) add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end @@ -378,12 +385,12 @@ class FeedManager # @param [Status] status # @param [Integer] receiver_id # @param [Hash] crutches - # @return [Boolean] - def filter_from_home?(status, receiver_id, crutches, timeline_type = :home) - return false if receiver_id == status.account_id - return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) - return true if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present? - return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language) + # @return [void|Symbol] nil, :skip_home, or :filter + def filter_from_home(status, receiver_id, crutches, timeline_type = :home) + return if receiver_id == status.account_id + return :filter if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) + return :skip_home if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present? + return :filter if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language) check_for_blocks = crutches[:active_mentions][status.id] || [] check_for_blocks.push(status.account_id) @@ -393,24 +400,22 @@ class FeedManager check_for_blocks.concat(crutches[:active_mentions][status.reblog_of_id] || []) end - return true if check_for_blocks.any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] } - return true if crutches[:blocked_by][status.account_id] + return :filter if check_for_blocks.any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] } + return :filter if crutches[:blocked_by][status.account_id] if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply should_filter = !crutches[:following][status.in_reply_to_account_id] # and I'm not following the person it's a reply to should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply - - return !!should_filter elsif status.reblog? # Filter out a reblog should_filter = crutches[:hiding_reblogs][status.account_id] # if the reblogger's reblogs are suppressed should_filter ||= crutches[:blocked_by][status.reblog.account_id] # or if the author of the reblogged status is blocking me should_filter ||= crutches[:domain_blocking][status.reblog.account.domain] # or the author's domain is blocked - - return !!should_filter + else + should_filter = false end - false + should_filter ? :filter : nil end # Check if status should not be added to the mentions feed diff --git a/app/lib/http_signature_draft.rb b/app/lib/http_signature_draft.rb new file mode 100644 index 000000000..fc0d498b2 --- /dev/null +++ b/app/lib/http_signature_draft.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# This implements an older draft of HTTP Signatures: +# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures + +class HttpSignatureDraft + REQUEST_TARGET = '(request-target)' + + def initialize(keypair, key_id, full_path: true) + @keypair = keypair + @key_id = key_id + @full_path = full_path + end + + def request_target(verb, url) + if url.query.nil? || !@full_path + "#{verb} #{url.path}" + else + "#{verb} #{url.path}?#{url.query}" + end + end + + def sign(signed_headers, verb, url) + signed_headers = signed_headers.merge(REQUEST_TARGET => request_target(verb, url)) + signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") + algorithm = 'rsa-sha256' + signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) + + "keyId=\"#{@key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" + end +end diff --git a/app/lib/request.rb b/app/lib/request.rb index 4f3f3ff43..330ab5956 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -61,8 +61,6 @@ class PerOperationWithDeadline < HTTP::Timeout::PerOperation end class Request - REQUEST_TARGET = '(request-target)' - # We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening # and 5s timeout on the TLS handshake, meaning the worst case should take # about 15s in total @@ -78,11 +76,21 @@ class Request @http_client = options.delete(:http_client) @allow_local = options.delete(:allow_local) @full_path = !options.delete(:omit_query_string) - @options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket) - @options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT) + @options = { + follow: { + max_hops: 3, + on_redirect: ->(response, request) { re_sign_on_redirect(response, request) }, + }, + }.merge(options).merge( + socket_class: use_proxy? || @allow_local ? ProxySocket : Socket, + timeout_class: PerOperationWithDeadline, + timeout_options: TIMEOUT + ) @options = @options.merge(proxy_url) if use_proxy? @headers = {} + @signing = nil + raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service? set_common_headers! @@ -92,8 +100,9 @@ class Request def on_behalf_of(actor, sign_with: nil) raise ArgumentError, 'actor must not be nil' if actor.nil? - @actor = actor - @keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @actor.keypair + key_id = ActivityPub::TagManager.instance.key_uri_for(actor) + keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : actor.keypair + @signing = HttpSignatureDraft.new(keypair, key_id, full_path: @full_path) self end @@ -125,7 +134,7 @@ class Request end def headers - (@actor ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET) + (@signing ? @headers.merge('Signature' => signature) : @headers) end class << self @@ -140,14 +149,13 @@ class Request end def http_client - HTTP.use(:auto_inflate).follow(max_hops: 3) + HTTP.use(:auto_inflate) end end private def set_common_headers! - @headers[REQUEST_TARGET] = request_target @headers['User-Agent'] = Mastodon::Version.user_agent @headers['Host'] = @url.host @headers['Date'] = Time.now.utc.httpdate @@ -158,31 +166,28 @@ class Request @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}" end - def request_target - if @url.query.nil? || !@full_path - "#{@verb} #{@url.path}" - else - "#{@verb} #{@url.path}?#{@url.query}" - end - end - def signature - algorithm = 'rsa-sha256' - signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) - - "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" + @signing.sign(@headers.without('User-Agent', 'Accept-Encoding'), @verb, @url) end - def signed_string - signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") - end + def re_sign_on_redirect(_response, request) + # Delete existing signature if there is one, since it will be invalid + request.headers.delete('Signature') - def signed_headers - @headers.without('User-Agent', 'Accept-Encoding') - end + return unless @signing.present? && @verb == :get - def key_id - ActivityPub::TagManager.instance.key_uri_for(@actor) + signed_headers = request.headers.to_h.slice(*@headers.keys) + unless @headers.keys.all? { |key| signed_headers.key?(key) } + # We have lost some headers in the process, so don't sign the new + # request, in order to avoid issuing a valid signature with fewer + # conditions than expected. + + Rails.logger.warn { "Some headers (#{@headers.keys - signed_headers.keys}) have been lost on redirect from {@uri} to #{request.uri}, this should not happen. Skipping signatures" } + return + end + + signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri)) + request.headers['Signature'] = signature_value end def http_client diff --git a/app/models/account.rb b/app/models/account.rb index 5d6e8864d..d0ddca994 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -133,6 +133,7 @@ class Account < ApplicationRecord scope :by_domain_and_subdomains, ->(domain) { where(domain: Instance.by_domain_and_subdomains(domain).select(:domain)) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } + scope :with_username, ->(value) { value.is_a?(Array) ? where(arel_table[:username].lower.in(value.map { |x| x.to_s.downcase })) : where(arel_table[:username].lower.eq(value.to_s.downcase)) } after_update_commit :trigger_update_webhooks diff --git a/app/models/poll.rb b/app/models/poll.rb index 72f04f00a..645298e88 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -34,7 +34,8 @@ class Poll < ApplicationRecord validates :options, presence: true validates :expires_at, presence: true, if: :local? - validates_with PollValidator, on: :create, if: :local? + validates_with PollOptionsValidator, if: :local? + validates_with PollExpirationValidator, if: -> { local? && expires_at_changed? } scope :attached, -> { where.not(status_id: nil) } scope :unattached, -> { where(status_id: nil) } diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 14aeda946..f8e837911 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -68,10 +68,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer }, polls: { - max_options: PollValidator::MAX_OPTIONS, - max_characters_per_option: PollValidator::MAX_OPTION_CHARS, - min_expiration: PollValidator::MIN_EXPIRATION, - max_expiration: PollValidator::MAX_EXPIRATION, + max_options: PollOptionsValidator::MAX_OPTIONS, + max_characters_per_option: PollOptionsValidator::MAX_OPTION_CHARS, + min_expiration: PollExpirationValidator::MIN_EXPIRATION, + max_expiration: PollExpirationValidator::MAX_EXPIRATION, }, translation: { diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index 99d1b2bd6..ac2807ecb 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -78,10 +78,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer }, polls: { - max_options: PollValidator::MAX_OPTIONS, - max_characters_per_option: PollValidator::MAX_OPTION_CHARS, - min_expiration: PollValidator::MIN_EXPIRATION, - max_expiration: PollValidator::MAX_EXPIRATION, + max_options: PollOptionsValidator::MAX_OPTIONS, + max_characters_per_option: PollOptionsValidator::MAX_OPTION_CHARS, + min_expiration: PollExpirationValidator::MIN_EXPIRATION, + max_expiration: PollExpirationValidator::MAX_EXPIRATION, }, } end diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index c4a5f5115..91e297649 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -186,7 +186,26 @@ class ActivityPub::ProcessStatusUpdateService < BaseService end def update_tags! - @status.tags = Tag.find_or_create_by_names(@raw_tags) + previous_tags = @status.tags.to_a + current_tags = @status.tags = Tag.find_or_create_by_names(@raw_tags) + + return unless @status.distributable? + + added_tags = current_tags - previous_tags + + unless added_tags.empty? + @account.featured_tags.where(tag_id: added_tags.pluck(:id)).find_each do |featured_tag| + featured_tag.increment(@status.created_at) + end + end + + removed_tags = previous_tags - current_tags + + return if removed_tags.empty? + + @account.featured_tags.where(tag_id: removed_tags.pluck(:id)).find_each do |featured_tag| + featured_tag.decrement(@status) + end end def update_mentions! diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb index a9aab653c..82d84a2f2 100644 --- a/app/services/activitypub/synchronize_followers_service.rb +++ b/app/services/activitypub/synchronize_followers_service.rb @@ -4,32 +4,46 @@ class ActivityPub::SynchronizeFollowersService < BaseService include JsonLdHelper include Payloadable + MAX_COLLECTION_PAGES = 10 + def call(account, partial_collection_url) @account = account + @expected_followers_ids = [] - items = collection_items(partial_collection_url) - return if items.nil? - - # There could be unresolved accounts (hence the call to .compact) but this - # should never happen in practice, since in almost all cases we keep an - # Account record, and should we not do that, we should have sent a Delete. - # In any case there is not much we can do if that occurs. - @expected_followers = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) } + return unless process_collection!(partial_collection_url) remove_unexpected_local_followers! - handle_unexpected_outgoing_follows! end private + def process_page!(items) + page_expected_followers = extract_local_followers(items) + @expected_followers_ids.concat(page_expected_followers.pluck(:id)) + + handle_unexpected_outgoing_follows!(page_expected_followers) + end + + def extract_local_followers(items) + # There could be unresolved accounts (hence the call to .filter_map) but this + # should never happen in practice, since in almost all cases we keep an + # Account record, and should we not do that, we should have sent a Delete. + # In any case there is not much we can do if that occurs. + + # TODO: this will need changes when switching to numeric IDs + + usernames = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username)&.downcase } + Account.local.with_username(usernames) + end + def remove_unexpected_local_followers! - @account.followers.local.where.not(id: @expected_followers.map(&:id)).each do |unexpected_follower| + @account.followers.local.where.not(id: @expected_followers_ids).reorder(nil).find_each do |unexpected_follower| UnfollowService.new.call(unexpected_follower, @account) end end - def handle_unexpected_outgoing_follows! - @expected_followers.each do |expected_follower| + def handle_unexpected_outgoing_follows!(expected_followers) + expected_followers.each do |expected_follower| next if expected_follower.following?(@account) if expected_follower.requested?(@account) @@ -50,18 +64,33 @@ class ActivityPub::SynchronizeFollowersService < BaseService Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)) end - def collection_items(collection_or_uri) - collection = fetch_collection(collection_or_uri) - return unless collection.is_a?(Hash) + # Only returns true if the whole collection has been processed + def process_collection!(collection_uri, max_pages: MAX_COLLECTION_PAGES) + collection = fetch_collection(collection_uri) + return false unless collection.is_a?(Hash) collection = fetch_collection(collection['first']) if collection['first'].present? - return unless collection.is_a?(Hash) + while collection.is_a?(Hash) + process_page!(as_array(collection_page_items(collection))) + + max_pages -= 1 + + return true if collection['next'].blank? # We reached the end of the collection + return false if max_pages <= 0 # We reached our pages limit + + collection = fetch_collection(collection['next']) + end + + false + end + + def collection_page_items(collection) case collection['type'] when 'Collection', 'CollectionPage' - as_array(collection['items']) + collection['items'] when 'OrderedCollection', 'OrderedCollectionPage' - as_array(collection['orderedItems']) + collection['orderedItems'] end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index e79c2d3d8..65743e360 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -95,7 +95,7 @@ class SuspendAccountService < BaseService end end - CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled + CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled end end end diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb index 93cd04a94..eec570db5 100644 --- a/app/services/unsuspend_account_service.rb +++ b/app/services/unsuspend_account_service.rb @@ -91,7 +91,7 @@ class UnsuspendAccountService < BaseService end end - CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled + CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled end end end diff --git a/app/validators/poll_expiration_validator.rb b/app/validators/poll_expiration_validator.rb new file mode 100644 index 000000000..ea8b08e18 --- /dev/null +++ b/app/validators/poll_expiration_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PollExpirationValidator < ActiveModel::Validator + MAX_EXPIRATION = 1.month.freeze + MIN_EXPIRATION = 5.minutes.freeze + + def validate(poll) + current_time = Time.now.utc + + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && (poll.expires_at - current_time).ceil < MIN_EXPIRATION + end +end diff --git a/app/validators/poll_validator.rb b/app/validators/poll_options_validator.rb similarity index 62% rename from app/validators/poll_validator.rb rename to app/validators/poll_options_validator.rb index 0ae295d4b..1adbc3a0f 100644 --- a/app/validators/poll_validator.rb +++ b/app/validators/poll_options_validator.rb @@ -3,17 +3,11 @@ class PollValidator < ActiveModel::Validator MAX_OPTIONS = 12 MAX_OPTION_CHARS = 50 - MAX_EXPIRATION = 1.month.freeze - MIN_EXPIRATION = 5.minutes.freeze def validate(poll) - current_time = Time.now.utc - poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size - poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION - poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && (poll.expires_at - current_time).ceil < MIN_EXPIRATION end end diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb index a04ac621f..9a418f0f3 100644 --- a/app/workers/activitypub/update_distribution_worker.rb +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker + DEBOUNCE_DELAY = 5.seconds + sidekiq_options queue: 'push', lock: :until_executed, lock_ttl: 1.day.to_i # Distribute an profile update to servers that might have a copy diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index fd7dbd30d..e883daf3e 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -29,27 +29,31 @@ class FeedInsertWorker private def check_and_insert - if feed_filtered? + filter_result = feed_filter + + if filter_result perform_unpush if update? else perform_push - perform_notify if notify? end + + perform_notify if notify?(filter_result) end - def feed_filtered? + def feed_filter case @type when :home - FeedManager.instance.filter?(:home, @status, @follower) + FeedManager.instance.filter(:home, @status, @follower) when :tags - FeedManager.instance.filter?(:tags, @status, @follower) + FeedManager.instance.filter(:tags, @status, @follower) when :list - FeedManager.instance.filter?(:list, @status, @list) + FeedManager.instance.filter(:list, @status, @list) end end - def notify? - return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id) + def notify?(filter_result) + return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id) || + filter_result == :filter Follow.find_by(account: @follower, target_account: @status.account)&.notify? end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 8125b335f..d4142dc7d 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -122,7 +122,7 @@ class Rack::Attack end throttle('throttle_email_confirmations/ip', limit: 25, period: 5.minutes) do |req| - req.throttleable_remote_ip if req.post? && (req.path_matches?('/auth/confirmation') || req.path == '/api/v1/emails/confirmations') + req.throttleable_remote_ip if (req.post? && (req.path_matches?('/auth/confirmation') || req.path == '/api/v1/emails/confirmations')) || ((req.put? || req.patch?) && req.path_matches?('/auth/setup')) end throttle('throttle_email_confirmations/email', limit: 5, period: 30.minutes) do |req| @@ -133,6 +133,14 @@ class Rack::Attack end end + throttle('throttle_auth_setup/email', limit: 5, period: 10.minutes) do |req| + req.params.dig('user', 'email').presence if (req.put? || req.patch?) && req.path_matches?('/auth/setup') + end + + throttle('throttle_auth_setup/account', limit: 5, period: 10.minutes) do |req| + req.warden_user_id if (req.put? || req.patch?) && req.path_matches?('/auth/setup') + end + throttle('throttle_login_attempts/ip', limit: 25, period: 5.minutes) do |req| req.throttleable_remote_ip if req.post? && req.path_matches?('/auth/sign_in') end diff --git a/docker-compose.yml b/docker-compose.yml index d6fa5ebfe..84ec231d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: web: build: . - image: ghcr.io/mastodon/mastodon:v4.2.15 + image: ghcr.io/mastodon/mastodon:v4.2.20 restart: always env_file: .env.production command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000" @@ -77,7 +77,7 @@ services: streaming: build: . - image: ghcr.io/mastodon/mastodon:v4.2.15 + image: ghcr.io/mastodon/mastodon:v4.2.20 restart: always env_file: .env.production command: node ./streaming @@ -95,7 +95,7 @@ services: sidekiq: build: . - image: ghcr.io/mastodon/mastodon:v4.2.15 + image: ghcr.io/mastodon/mastodon:v4.2.20 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index eb1e212ff..acb2f89b0 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 15 + 20 end def default_prerelease diff --git a/lib/redis/namespace_extensions.rb b/lib/redis/namespace_extensions.rb index 9af59c296..2be738b04 100644 --- a/lib/redis/namespace_extensions.rb +++ b/lib/redis/namespace_extensions.rb @@ -5,6 +5,10 @@ class Redis def exists?(...) call_with_namespace('exists?', ...) end + + def with + yield self + end end end diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb index 520e6cba9..468a95636 100644 --- a/lib/sanitize_ext/sanitize_config.rb +++ b/lib/sanitize_ext/sanitize_config.rb @@ -91,19 +91,17 @@ class Sanitize ] ) - MASTODON_OEMBED ||= freeze_config( - elements: %w(audio embed iframe source video), + MASTODON_OEMBED = freeze_config( + elements: %w(audio iframe source video), attributes: { 'audio' => %w(controls), - 'embed' => %w(height src type width), 'iframe' => %w(allowfullscreen frameborder height scrolling src width), 'source' => %w(src type), 'video' => %w(controls height loop width), }, protocols: { - 'embed' => { 'src' => HTTP_PROTOCOLS }, 'iframe' => { 'src' => HTTP_PROTOCOLS }, 'source' => { 'src' => HTTP_PROTOCOLS }, }, diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index b5d5c37a9..e050ab47c 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -27,7 +27,7 @@ describe Api::V1::Accounts::CredentialsController do describe 'with valid data' do before do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) patch :update, params: { display_name: "Alice Isn't Dead", @@ -58,7 +58,7 @@ describe Api::V1::Accounts::CredentialsController do end it 'queues up an account update distribution' do - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(user.account_id) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, user.account_id) end end diff --git a/spec/controllers/settings/privacy_controller_spec.rb b/spec/controllers/settings/privacy_controller_spec.rb new file mode 100644 index 000000000..4fccf098c --- /dev/null +++ b/spec/controllers/settings/privacy_controller_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Settings::PrivacyController do + render_views + + let!(:user) { Fabricate(:user) } + let(:account) { user.account } + + before do + sign_in user, scope: :user + end + + describe 'GET #show' do + before do + get :show + end + + it 'returns http success with private cache control headers', :aggregate_failures do + expect(response) + .to have_http_status(200) + expect(response.headers['Cache-Control']).to include('private, no-store') + end + end + + describe 'PUT #update' do + context 'when update succeeds' do + before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) + end + + it 'updates the user profile' do + put :update, params: { account: { discoverable: '1', settings: { indexable: '1' } } } + + expect(account.reload.discoverable) + .to be(true) + + expect(response) + .to redirect_to(settings_privacy_path) + + expect(ActivityPub::UpdateDistributionWorker) + .to have_received(:perform_in).with(anything, account.id) + end + end + + context 'when update fails' do + before do + allow(UpdateAccountService).to receive(:new).and_return(failing_update_service) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) + end + + it 'updates the user profile' do + put :update, params: { account: { discoverable: '1', settings: { indexable: '1' } } } + + expect(response) + .to render_template(:show) + + expect(ActivityPub::UpdateDistributionWorker) + .to_not have_received(:perform_in) + end + + private + + def failing_update_service + instance_double(UpdateAccountService, call: false) + end + end + end +end diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb index 806fad19a..8235eaaa4 100644 --- a/spec/controllers/settings/profiles_controller_spec.rb +++ b/spec/controllers/settings/profiles_controller_spec.rb @@ -32,23 +32,23 @@ RSpec.describe Settings::ProfilesController do end it 'updates the user profile' do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) put :update, params: { account: { display_name: 'New name' } } expect(account.reload.display_name).to eq 'New name' expect(response).to redirect_to(settings_profile_path) - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id) end end describe 'PUT #update with new profile image' do it 'updates profile image' do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) expect(account.avatar.instance.avatar_file_name).to be_nil put :update, params: { account: { avatar: fixture_file_upload('avatar.gif', 'image/gif') } } expect(response).to redirect_to(settings_profile_path) expect(account.reload.avatar.instance.avatar_file_name).to_not be_nil - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id) end end end diff --git a/spec/lib/account_reach_finder_spec.rb b/spec/lib/account_reach_finder_spec.rb index e5d85656a..ed16c07c2 100644 --- a/spec/lib/account_reach_finder_spec.rb +++ b/spec/lib/account_reach_finder_spec.rb @@ -13,13 +13,28 @@ RSpec.describe AccountReachFinder do let(:ap_mentioned_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3', domain: 'example.com') } let(:ap_mentioned_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-4', domain: 'example.org') } + let(:ap_followed_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-5', domain: 'example.com') } + let(:ap_followed_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-6', domain: 'example.org') } + + let(:ap_requested_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-7', domain: 'example.com') } + let(:ap_requested_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-8', domain: 'example.org') } + let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox', domain: 'example.com') } + let(:old_followed_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/old-followed-inbox', domain: 'example.com') } before do + travel_to(2.months.ago) { account.follow!(old_followed_account) } + ap_follower_example_com.follow!(account) ap_follower_example_org.follow!(account) ap_follower_with_shared.follow!(account) + account.follow!(ap_followed_example_com) + account.follow!(ap_followed_example_org) + + account.request_follow!(ap_requested_example_com) + account.request_follow!(ap_requested_example_org) + Fabricate(:status, account: account).tap do |status| status.mentions << Mention.new(account: ap_follower_example_com) status.mentions << Mention.new(account: ap_mentioned_with_shared) @@ -38,16 +53,36 @@ RSpec.describe AccountReachFinder do end describe '#inboxes' do - it 'includes the preferred inbox URL of followers' do - expect(described_class.new(account).inboxes).to include(*[ap_follower_example_com, ap_follower_example_org, ap_follower_with_shared].map(&:preferred_inbox_url)) + subject { described_class.new(account).inboxes } + + it 'includes the preferred inbox URL of followers and recently mentioned accounts but not unrelated users' do + expect(subject) + .to include(*follower_inbox_urls) + .and include(*mentioned_account_inbox_urls) + .and include(*recently_followed_inbox_urls) + .and include(*recently_requested_inbox_urls) + .and not_include(unrelated_account.preferred_inbox_url) + .and not_include(old_followed_account.preferred_inbox_url) end - it 'includes the preferred inbox URL of recently-mentioned accounts' do - expect(described_class.new(account).inboxes).to include(*[ap_mentioned_with_shared, ap_mentioned_example_com, ap_mentioned_example_org].map(&:preferred_inbox_url)) + def follower_inbox_urls + [ap_follower_example_com, ap_follower_example_org, ap_follower_with_shared] + .map(&:preferred_inbox_url) end - it 'does not include the inbox of unrelated users' do - expect(described_class.new(account).inboxes).to_not include(unrelated_account.preferred_inbox_url) + def mentioned_account_inbox_urls + [ap_mentioned_with_shared, ap_mentioned_example_com, ap_mentioned_example_org] + .map(&:preferred_inbox_url) + end + + def recently_followed_inbox_urls + [ap_followed_example_com, ap_followed_example_org] + .map(&:preferred_inbox_url) + end + + def recently_requested_inbox_urls + [ap_requested_example_com, ap_requested_example_org] + .map(&:preferred_inbox_url) end end end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 7594efd59..bf88027cf 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -130,10 +130,6 @@ RSpec.describe ActivityPub::Activity::Create do context 'when fetching' do subject { described_class.new(json, sender) } - before do - subject.perform - end - context 'when object publication date is below ISO8601 range' do let(:object_json) do { @@ -145,6 +141,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status with a valid creation date', :aggregate_failures do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -165,6 +163,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status with a valid creation date', :aggregate_failures do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -186,6 +186,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status with appropriate creation and edition dates', :aggregate_failures do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -209,17 +211,13 @@ RSpec.describe ActivityPub::Activity::Create do } end - it 'creates status' do + it 'creates status and does not mark it as edited' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil expect(status.text).to eq 'Lorem ipsum' - end - - it 'does not mark status as edited' do - status = sender.statuses.first - - expect(status).to_not be_nil expect(status.edited?).to be false end end @@ -234,7 +232,7 @@ RSpec.describe ActivityPub::Activity::Create do end it 'does not create a status' do - expect(sender.statuses.count).to be_zero + expect { subject.perform }.to_not change(sender.statuses, :count) end end @@ -248,6 +246,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -255,6 +255,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'missing to/cc defaults to direct privacy' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -273,6 +275,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -291,6 +295,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -309,6 +315,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -327,6 +335,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -345,6 +355,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -363,6 +375,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -381,6 +395,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -403,6 +419,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -422,15 +440,13 @@ RSpec.describe ActivityPub::Activity::Create do } end - it 'creates status' do + it 'creates status with a silent mention' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil expect(status.visibility).to eq 'limited' - end - - it 'creates silent mention' do - status = sender.statuses.first expect(status.mentions.first).to be_silent end end @@ -452,6 +468,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -472,6 +490,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -500,6 +520,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -522,6 +544,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil end @@ -544,6 +568,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -569,6 +595,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -594,6 +622,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -619,6 +649,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -642,6 +674,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil end @@ -664,6 +698,42 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.tags.map(&:name)).to include('test') + end + end + + context 'with featured hashtags' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + tag: [ + { + type: 'Hashtag', + href: 'http://example.com/blah', + name: '#test', + }, + ], + } + end + + before do + sender.featured_tags.create!(name: 'test') + end + + it 'creates status and updates featured tag' do + expect { subject.perform } + .to change(sender.statuses, :count).by(1) + .and change { sender.featured_tags.first.reload.statuses_count }.by(1) + .and change { sender.featured_tags.first.reload.last_status_at }.from(nil).to(be_present) + status = sender.statuses.first expect(status).to_not be_nil @@ -687,6 +757,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil end @@ -709,6 +781,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil end @@ -733,6 +807,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -759,6 +835,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil @@ -784,6 +862,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil end @@ -805,6 +885,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil end @@ -835,13 +917,13 @@ RSpec.describe ActivityPub::Activity::Create do } end - it 'creates status' do + it 'creates status with a poll' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + status = sender.statuses.first expect(status).to_not be_nil expect(status.poll).to_not be_nil - end - it 'creates a poll' do poll = sender.polls.first expect(poll).to_not be_nil expect(poll.status).to_not be_nil @@ -864,6 +946,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'adds a vote to the poll with correct uri' do + expect { subject.perform }.to change(poll.votes, :count).by(1) + vote = poll.votes.first expect(vote).to_not be_nil expect(vote.uri).to eq object_json[:id] @@ -889,6 +973,8 @@ RSpec.describe ActivityPub::Activity::Create do end it 'does not add a vote to the poll' do + expect { subject.perform }.to_not change(poll.votes, :count) + expect(poll.votes.first).to be_nil end end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index 8a867790a..61d4705a7 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -13,10 +13,13 @@ RSpec.describe ActivityPub::LinkedDataSignature do { '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => 'http://example.com/hello-world', + 'type' => 'Note', + 'content' => 'Hello world', } end - let(:json) { raw_json.merge('signature' => signature) } + let(:signed_json) { raw_json.merge('signature' => signature) } + let(:json) { signed_json } before do stub_jsonld_contexts! @@ -94,6 +97,54 @@ RSpec.describe ActivityPub::LinkedDataSignature do expect(subject.verify_actor!).to be_nil end end + + context 'when an attribute has been removed from the document' do + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + let(:json) { signed_json.without('content') } + + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + it 'returns nil' do + expect(subject.verify_actor!).to be_nil + end + end + + context 'when an attribute has been added to the document' do + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + let(:json) { signed_json.merge('attributedTo' => 'http://example.com/bob') } + + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + it 'returns nil' do + expect(subject.verify_actor!).to be_nil + end + end + + context 'when an existing attribute has been changed' do + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + let(:json) { signed_json.merge('content' => 'oops') } + + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + it 'returns nil' do + expect(subject.verify_actor!).to be_nil + end + end end describe '#sign!' do diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index f4dd42f84..e623fb2ac 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -162,6 +162,7 @@ RSpec.describe FeedManager do allow(List).to receive(:where).and_return(list) status = Fabricate(:status, text: 'I post a lot', account: bob) expect(described_class.instance.filter?(:home, status, alice)).to be true + expect(described_class.instance.filter(:home, status, alice)).to be :skip_home end it 'returns true for reblog from followee on exclusive list' do @@ -172,6 +173,7 @@ RSpec.describe FeedManager do status = Fabricate(:status, text: 'I post a lot', account: bob) reblog = Fabricate(:status, reblog: status, account: jeff) expect(described_class.instance.filter?(:home, reblog, alice)).to be true + expect(described_class.instance.filter(:home, reblog, alice)).to be :skip_home end it 'returns false for post from followee on non-exclusive list' do diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb index f179e6ca9..a31d07832 100644 --- a/spec/lib/request_pool_spec.rb +++ b/spec/lib/request_pool_spec.rb @@ -33,12 +33,12 @@ describe RequestPool do subject - threads = Array.new(20) do |_i| + threads = Array.new(5) do Thread.new do - 20.times do - subject.with('http://example.com') do |http_client| - http_client.get('/').flush - end + subject.with('http://example.com') do |http_client| + http_client.get('/').flush + # Nudge scheduler to yield and exercise the full pool + sleep(0.01) end end end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index f0861376b..a71655c10 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -58,14 +58,13 @@ describe Request do expect(a_request(:get, 'http://example.com')).to have_been_made.once end - it 'sets headers' do - expect { |block| subject.perform(&block) }.to yield_control - expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made - end + it 'makes a request with expected headers, yields, and closes the underlying connection' do + allow(subject.send(:http_client)).to receive(:close) - it 'closes underlying connection' do - expect_any_instance_of(HTTP::Client).to receive(:close) expect { |block| subject.perform(&block) }.to yield_control + + expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made + expect(subject.send(:http_client)).to have_received(:close) end it 'returns response which implements body_with_limit' do @@ -75,6 +74,29 @@ describe Request do end end + context 'with a redirect and HTTP signatures' do + let(:account) { Fabricate(:account) } + + before do + stub_request(:get, 'http://example.com').to_return(status: 301, headers: { Location: 'http://redirected.example.com/foo' }) + stub_request(:get, 'http://redirected.example.com/foo').to_return(body: 'lorem ipsum') + end + + it 'makes a request with expected headers and follows redirects' do + expect { |block| subject.on_behalf_of(account).perform(&block) }.to yield_control + + # request.headers includes the `Signature` sent for the first request + expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made.once + + # request.headers includes the `Signature`, but it has changed + expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.merge({ 'Host' => 'redirected.example.com' }))).to_not have_been_made + + # `with(headers: )` matching tests for inclusion, so strip `Signature` + # This doesn't actually test that there is a signature, but it tests that the original signature is not passed + expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.without('Signature').merge({ 'Host' => 'redirected.example.com' }))).to have_been_made.once + end + end + context 'with private host' do around do |example| WebMock.disable! diff --git a/spec/requests/api/v1/accounts/credentials_spec.rb b/spec/requests/api/v1/accounts/credentials_spec.rb index b13e79b12..6a7d35861 100644 --- a/spec/requests/api/v1/accounts/credentials_spec.rb +++ b/spec/requests/api/v1/accounts/credentials_spec.rb @@ -39,7 +39,25 @@ RSpec.describe 'credentials API' do patch '/api/v1/accounts/update_credentials', headers: headers, params: params end - let(:params) { { discoverable: true, locked: false, indexable: true } } + before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) + end + + let(:params) do + { + avatar: fixture_file_upload('avatar.gif', 'image/gif'), + discoverable: true, + display_name: "Alice Isn't Dead", + header: fixture_file_upload('attachment.jpg', 'image/jpeg'), + indexable: true, + locked: false, + note: 'Hello!', + source: { + privacy: 'unlisted', + sensitive: true, + }, + } + end it_behaves_like 'forbidden for wrong scope', 'read read:accounts' @@ -59,6 +77,27 @@ RSpec.describe 'credentials API' do }), locked: false, }) + + expect(ActivityPub::UpdateDistributionWorker) + .to have_received(:perform_in).with(anything, user.account_id) + end + + def expect_account_updates + expect(user.account.reload) + .to have_attributes( + display_name: eq("Alice Isn't Dead"), + note: 'Hello!', + avatar: exist, + header: exist + ) + end + + def expect_user_updates + expect(user.reload) + .to have_attributes( + setting_default_privacy: eq('unlisted'), + setting_default_sensitive: be(true) + ) end end end diff --git a/spec/requests/api/v1/instances/domain_blocks_spec.rb b/spec/requests/api/v1/instances/domain_blocks_spec.rb new file mode 100644 index 000000000..475ae478c --- /dev/null +++ b/spec/requests/api/v1/instances/domain_blocks_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain Blocks' do + describe 'GET /api/v1/instance/domain_blocks' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id).token } + + before { Fabricate(:domain_block) } + + context 'with domain blocks set to all' do + before { Setting.show_domain_blocks = 'all' } + + it 'returns http success' do + get api_v1_instance_domain_blocks_path + + expect(response) + .to have_http_status(200) + + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: 1)) + end + end + + context 'with domain blocks set to users' do + before { Setting.show_domain_blocks = 'users' } + + context 'without authentication token' do + it 'returns http not found' do + get api_v1_instance_domain_blocks_path + + expect(response) + .to have_http_status(404) + end + end + + context 'with authentication token' do + context 'with unapproved user' do + before { user.update(approved: false) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(404) + end + end + + context 'with unconfirmed user' do + before { user.update(confirmed_at: nil) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(404) + end + end + + context 'with disabled user' do + before { user.update(disabled: true) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(404) + end + end + + context 'with suspended user' do + before { user.account.update(suspended_at: Time.zone.now) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(403) + end + end + + context 'with moved user' do + before { user.account.update(moved_to_account_id: Fabricate(:account).id) } + + it 'returns http success' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(200) + + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: 1)) + end + end + + context 'with normal user' do + it 'returns http success' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(200) + + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: 1)) + end + end + end + end + + context 'with domain blocks set to disabled' do + before { Setting.show_domain_blocks = 'disabled' } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/profiles_spec.rb b/spec/requests/api/v1/profiles_spec.rb index 26a9b848e..b7760bce6 100644 --- a/spec/requests/api/v1/profiles_spec.rb +++ b/spec/requests/api/v1/profiles_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Deleting profile images' do describe 'DELETE /api/v1/profile' do before do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) end context 'when deleting an avatar' do @@ -48,12 +48,7 @@ RSpec.describe 'Deleting profile images' do account.reload expect(account.header).to exist - end - - it 'queues up an account update distribution' do - delete '/api/v1/profile/avatar', headers: headers - - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id) end end @@ -86,12 +81,7 @@ RSpec.describe 'Deleting profile images' do account.reload expect(account.header).to_not exist - end - - it 'queues up an account update distribution' do - delete '/api/v1/profile/header', headers: headers - - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id) end end end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 9d91f31cc..4048f37f2 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -292,16 +292,22 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do updated: '2021-09-08T22:39:25Z', tag: [ { type: 'Hashtag', name: 'foo' }, + { type: 'Hashtag', name: 'bar' }, ], } end before do - subject.call(status, json, json) + status.account.featured_tags.create!(name: 'bar') + status.account.featured_tags.create!(name: 'test') end - it 'updates tags' do - expect(status.tags.reload.map(&:name)).to eq %w(foo) + it 'updates tags and featured tags' do + expect { subject.call(status, json, json) } + .to change { status.tags.reload.pluck(:name) }.from(%w(test foo)).to(%w(foo bar)) + .and change { status.account.featured_tags.find_by(name: 'test').statuses_count }.by(-1) + .and change { status.account.featured_tags.find_by(name: 'bar').statuses_count }.by(1) + .and change { status.account.featured_tags.find_by(name: 'bar').last_status_at }.from(nil).to(be_present) end end diff --git a/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb index f62376ab9..1442edbea 100644 --- a/spec/services/activitypub/synchronize_followers_service_spec.rb +++ b/spec/services/activitypub/synchronize_followers_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do let(:bob) { Fabricate(:account, username: 'bob') } let(:eve) { Fabricate(:account, username: 'eve') } let(:mallory) { Fabricate(:account, username: 'mallory') } - let(:collection_uri) { 'http://example.com/partial-followers' } + let(:collection_uri) { 'https://example.com/partial-followers' } let(:items) do [ @@ -29,31 +29,33 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do }.with_indifferent_access end + around do |example| + Sidekiq::Testing.fake! do + example.run + Sidekiq::Worker.clear_all + end + end + + before do + alice.follow!(actor) + bob.follow!(actor) + mallory.request_follow!(actor) + end + shared_examples 'synchronizes followers' do before do - alice.follow!(actor) - bob.follow!(actor) - mallory.request_follow!(actor) - - allow(ActivityPub::DeliveryWorker).to receive(:perform_async) - subject.call(actor, collection_uri) end - it 'keeps expected followers' do - expect(alice.following?(actor)).to be true - end - - it 'removes local followers not in the remote list' do - expect(bob.following?(actor)).to be false - end - - it 'converts follow requests to follow relationships when they have been accepted' do - expect(mallory.following?(actor)).to be true - end - - it 'sends an Undo Follow to the actor' do - expect(ActivityPub::DeliveryWorker).to have_received(:perform_async).with(anything, eve.id, actor.inbox_url) + it 'maintains following records and sends Undo Follow to actor' do + expect(alice) + .to be_following(actor) # Keep expected followers + expect(bob) + .to_not be_following(actor) # Remove local followers not in remote list + expect(mallory) + .to be_following(actor) # Convert follow request to follow when accepted + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor end end @@ -83,7 +85,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do it_behaves_like 'synchronizes followers' end - context 'when the endpoint is a paginated Collection of actor URIs' do + context 'when the endpoint is a single-page paginated Collection of actor URIs' do let(:payload) do { '@context': 'https://www.w3.org/ns/activitystreams', @@ -103,5 +105,106 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do it_behaves_like 'synchronizes followers' end + + context 'when the endpoint is a paginated Collection of actor URIs split across multiple pages' do + before do + stub_request(:get, 'https://example.com/partial-followers') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: 'https://example.com/partial-followers', + first: 'https://example.com/partial-followers/1', + })) + + stub_request(:get, 'https://example.com/partial-followers/1') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'CollectionPage', + id: 'https://example.com/partial-followers/1', + partOf: 'https://example.com/partial-followers', + next: 'https://example.com/partial-followers/2', + items: [alice, eve].map { |account| ActivityPub::TagManager.instance.uri_for(account) }, + })) + + stub_request(:get, 'https://example.com/partial-followers/2') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'CollectionPage', + id: 'https://example.com/partial-followers/2', + partOf: 'https://example.com/partial-followers', + items: ActivityPub::TagManager.instance.uri_for(mallory), + })) + end + + it_behaves_like 'synchronizes followers' + end + + context 'when the endpoint is a paginated Collection of actor URIs split across, but one page errors out' do + before do + stub_request(:get, 'https://example.com/partial-followers') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: 'https://example.com/partial-followers', + first: 'https://example.com/partial-followers/1', + })) + + stub_request(:get, 'https://example.com/partial-followers/1') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'CollectionPage', + id: 'https://example.com/partial-followers/1', + partOf: 'https://example.com/partial-followers', + next: 'https://example.com/partial-followers/2', + items: [mallory].map { |account| ActivityPub::TagManager.instance.uri_for(account) }, + })) + + stub_request(:get, 'https://example.com/partial-followers/2') + .to_return(status: 404) + end + + it 'confirms pending follow request but does not remove extra followers' do + previous_follower_ids = actor.followers.pluck(:id) + + subject.call(actor, collection_uri) + + expect(previous_follower_ids - actor.followers.reload.pluck(:id)) + .to be_empty + expect(mallory) + .to be_following(actor) + end + end + + context 'when the endpoint is a paginated Collection of actor URIs with more pages than we allow' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: collection_uri, + first: { + type: 'CollectionPage', + partOf: collection_uri, + items: items, + next: "#{collection_uri}/page2", + }, + }.with_indifferent_access + end + + before do + stub_const('ActivityPub::SynchronizeFollowersService::MAX_COLLECTION_PAGES', 1) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'confirms pending follow request but does not remove extra followers' do + previous_follower_ids = actor.followers.pluck(:id) + + subject.call(actor, collection_uri) + + expect(previous_follower_ids - actor.followers.reload.pluck(:id)) + .to be_empty + expect(mallory) + .to be_following(actor) + end + end end end diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb index edb705008..8044730e6 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -3,6 +3,13 @@ require 'rails_helper' RSpec.describe SuspendAccountService, type: :service do + around do |example| + Sidekiq::Testing.fake! do + example.run + Sidekiq::Worker.clear_all + end + end + shared_examples 'common behavior' do subject { described_class.new.call(account) } @@ -11,15 +18,20 @@ RSpec.describe SuspendAccountService, type: :service do before do allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil) + allow(Rails.configuration.x).to receive(:cache_buster_enabled).and_return(true) local_follower.follow!(account) list.accounts << account account.suspend! + + Fabricate(:media_attachment, file: attachment_fixture('boop.ogg'), account: account) end - it "unmerges from local followers' feeds" do - subject + it 'unmerges from feeds of local followers and changes file mode' do + expect { subject } + .to change { File.stat(account.media_attachments.first.file.path).mode } + .and enqueue_sidekiq_job(CacheBusterWorker).with(account.media_attachments.first.file.url(:original)) expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower) expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list) end @@ -30,17 +42,12 @@ RSpec.describe SuspendAccountService, type: :service do end describe 'suspending a local account' do - def match_update_actor_request(req, account) - json = JSON.parse(req.body) + def match_update_actor_request(json, account) + json = JSON.parse(json) actor_id = ActivityPub::TagManager.instance.uri_for(account) json['type'] == 'Update' && json['actor'] == actor_id && json['object']['id'] == actor_id && json['object']['suspended'] end - before do - stub_request(:post, 'https://alice.com/inbox').to_return(status: 201) - stub_request(:post, 'https://bob.com/inbox').to_return(status: 201) - end - include_examples 'common behavior' do let!(:account) { Fabricate(:account) } let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') } @@ -53,22 +60,21 @@ RSpec.describe SuspendAccountService, type: :service do it 'sends an update actor to followers and reporters' do subject - expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once - expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once + + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_follower.inbox_url) + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_reporter.inbox_url) end end end describe 'suspending a remote account' do - def match_reject_follow_request(req, account, followee) - json = JSON.parse(req.body) + def match_reject_follow_request(json, account, followee) + json = JSON.parse(json) json['type'] == 'Reject' && json['actor'] == ActivityPub::TagManager.instance.uri_for(followee) && json['object']['actor'] == account.uri end - before do - stub_request(:post, 'https://bob.com/inbox').to_return(status: 201) - end - include_examples 'common behavior' do let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } let!(:local_followee) { Fabricate(:account) } @@ -79,7 +85,9 @@ RSpec.describe SuspendAccountService, type: :service do it 'sends a reject follow' do subject - expect(a_request(:post, account.inbox_url).with { |req| match_reject_follow_request(req, account, local_followee) }).to have_been_made.once + + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(satisfying { |json| match_reject_follow_request(json, account, local_followee) }, local_followee.id, account.inbox_url) end end end diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_expiration_validator_spec.rb similarity index 63% rename from spec/validators/poll_validator_spec.rb rename to spec/validators/poll_expiration_validator_spec.rb index 95feb043d..a4ad33376 100644 --- a/spec/validators/poll_validator_spec.rb +++ b/spec/validators/poll_expiration_validator_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe PollValidator, type: :validator do +RSpec.describe PollExpirationValidator, type: :validator do describe '#validate' do before do validator.validate(poll) @@ -14,16 +14,24 @@ RSpec.describe PollValidator, type: :validator do let(:options) { %w(foo bar) } let(:expires_at) { 1.day.from_now } - it 'have no errors' do + it 'has no errors' do expect(errors).to_not have_received(:add) end - context 'when expires is just 5 min ago' do + context 'when the poll expires in 5 min from now' do let(:expires_at) { 5.minutes.from_now } - it 'not calls errors add' do + it 'has no errors' do expect(errors).to_not have_received(:add) end end + + context 'when the poll expires in the past' do + let(:expires_at) { 5.minutes.ago } + + it 'has errors' do + expect(errors).to have_received(:add) + end + end end end diff --git a/spec/validators/poll_options_validator_spec.rb b/spec/validators/poll_options_validator_spec.rb new file mode 100644 index 000000000..9e4ec744d --- /dev/null +++ b/spec/validators/poll_options_validator_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PollOptionsValidator do + describe '#validate' do + before do + validator.validate(poll) + end + + let(:validator) { described_class.new } + let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } + let(:options) { %w(foo bar) } + let(:expires_at) { 1.day.from_now } + + it 'has no errors' do + expect(errors).to_not have_received(:add) + end + + context 'when the poll has duplicate options' do + let(:options) { %w(foo foo) } + + it 'adds errors' do + expect(errors).to have_received(:add) + end + end + + context 'when the poll has no options' do + let(:options) { [] } + + it 'adds errors' do + expect(errors).to have_received(:add) + end + end + + context 'when the poll has too many options' do + let(:options) { Array.new(described_class::MAX_OPTIONS + 1) { |i| "option #{i}" } } + + it 'adds errors' do + expect(errors).to have_received(:add) + end + end + end +end diff --git a/spec/workers/feed_insert_worker_spec.rb b/spec/workers/feed_insert_worker_spec.rb index 97c73c599..674023d0d 100644 --- a/spec/workers/feed_insert_worker_spec.rb +++ b/spec/workers/feed_insert_worker_spec.rb @@ -8,6 +8,7 @@ describe FeedInsertWorker do describe 'perform' do let(:follower) { Fabricate(:account) } let(:status) { Fabricate(:status) } + let(:list) { Fabricate(:list) } context 'when there are no records' do it 'skips push with missing status' do @@ -31,7 +32,7 @@ describe FeedInsertWorker do context 'when there are real records' do it 'skips the push when there is a filter' do - instance = instance_double(FeedManager, push_to_home: nil, filter?: true) + instance = instance_double(FeedManager, push_to_home: nil, filter?: true, filter: :filter) allow(FeedManager).to receive(:instance).and_return(instance) result = subject.perform(status.id, follower.id) @@ -40,13 +41,31 @@ describe FeedInsertWorker do end it 'pushes the status onto the home timeline without filter' do - instance = instance_double(FeedManager, push_to_home: nil, filter?: false) + instance = instance_double(FeedManager, push_to_home: nil, filter?: false, filter: nil) allow(FeedManager).to receive(:instance).and_return(instance) result = subject.perform(status.id, follower.id) expect(result).to be_nil expect(instance).to have_received(:push_to_home).with(follower, status, update: nil) end + + it 'pushes the status onto the tags timeline without filter' do + instance = instance_double(FeedManager, push_to_home: nil, filter?: false, filter: nil) + allow(FeedManager).to receive(:instance).and_return(instance) + result = subject.perform(status.id, follower.id, :tags) + + expect(result).to be_nil + expect(instance).to have_received(:push_to_home).with(follower, status, update: nil) + end + + it 'pushes the status onto the list timeline without filter' do + instance = instance_double(FeedManager, push_to_list: nil, filter?: false, filter: nil) + allow(FeedManager).to receive(:instance).and_return(instance) + result = subject.perform(status.id, list.id, :list) + + expect(result).to be_nil + expect(instance).to have_received(:push_to_list).with(list, status, update: nil) + end end end end diff --git a/streaming/index.js b/streaming/index.js index fce0d42cd..cf3806fc8 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -789,7 +789,7 @@ const startServer = async () => { // filtering of statuses: // Filter based on language: - if (Array.isArray(req.chosenLanguages) && payload.language !== null && req.chosenLanguages.indexOf(payload.language) === -1) { + if (Array.isArray(req.chosenLanguages) && req.chosenLanguages.indexOf(payload.language) === -1) { log.silly(req.requestId, `Message ${payload.id} filtered by language (${payload.language})`); return; }