From 4aab39f7c97d4417e1683f0487e6fd86c9336734 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 11 Feb 2025 18:05:07 +0100 Subject: [PATCH 01/43] Use github's native arm64 runners for docker builds (#33887) --- .github/workflows/build-container-image.yml | 160 ++++++++++++++------ .github/workflows/build-nightly.yml | 2 - .github/workflows/build-push-pr.yml | 2 - .github/workflows/build-releases.yml | 2 - .github/workflows/test-image-build.yml | 2 - .github/workflows/test-ruby.yml | 16 +- 6 files changed, 121 insertions(+), 63 deletions(-) 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..327ebc2db 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: |- @@ -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 }} @@ -204,7 +204,7 @@ jobs: 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 @@ -317,7 +317,7 @@ jobs: 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 From e8bfe2515bfde703b661fe538c1cdf592215cf19 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 13 Feb 2025 13:53:58 +0100 Subject: [PATCH 02/43] Fix exclusive lists interfering with notifications (#28162) --- app/lib/feed_manager.rb | 61 +++++++++++++------------ app/workers/feed_insert_worker.rb | 20 ++++---- spec/lib/feed_manager_spec.rb | 2 + spec/workers/feed_insert_worker_spec.rb | 23 +++++++++- 4 files changed, 68 insertions(+), 38 deletions(-) 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/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/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/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 From a251eb57d36004bb9969da73f1af828fec93e109 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 12 Feb 2025 17:00:30 +0100 Subject: [PATCH 03/43] Fix featured tags for remote accounts not being kept up to date (#33372) --- app/lib/activitypub/activity/create.rb | 10 ++ .../process_status_update_service.rb | 21 ++- spec/lib/activitypub/activity/create_spec.rb | 126 +++++++++++++++--- .../process_status_update_service_spec.rb | 12 +- 4 files changed, 145 insertions(+), 24 deletions(-) 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/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/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 7594efd59..f905d894a 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_within(0.1).of(Time.now.utc)) + 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/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 9d91f31cc..9b4cecb40 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_within(0.1).of(Time.now.utc)) end end From 5768cce8ff503705b1fa686fd2d3da3228ad0d63 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 2 Jan 2025 03:38:22 -0500 Subject: [PATCH 04/43] Fix intermittent failure on ap/activity/create spec timestamp check (#33406) --- spec/lib/activitypub/activity/create_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index f905d894a..bf88027cf 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -732,7 +732,7 @@ RSpec.describe ActivityPub::Activity::Create 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_within(0.1).of(Time.now.utc)) + .and change { sender.featured_tags.first.reload.last_status_at }.from(nil).to(be_present) status = sender.statuses.first From 53c3a56ac57e4c21d5996840f28edf4c9afbb7ed Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 2 Jan 2025 10:17:49 +0100 Subject: [PATCH 05/43] Fix intermittent failure on ap/activity/update spec timestamp check (#33425) --- spec/services/activitypub/process_status_update_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 9b4cecb40..4048f37f2 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -307,7 +307,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do .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_within(0.1).of(Time.now.utc)) + .and change { status.account.featured_tags.find_by(name: 'bar').last_status_at }.from(nil).to(be_present) end end From d6f89e1476b161be152ce4b266ff9fd462b46e8d Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 23 Jan 2025 18:56:33 +0100 Subject: [PATCH 06/43] Fix LDSignature tests (#33705) --- .../activitypub/linked_data_signature_spec.rb | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) 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 From b661192a12fa7777f798ef685f11db76d2aaf177 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jan 2025 15:38:18 +0100 Subject: [PATCH 07/43] Fix polls not being validated on edition (#33755) --- app/models/poll.rb | 3 +- app/serializers/rest/instance_serializer.rb | 8 ++-- .../rest/v1/instance_serializer.rb | 8 ++-- app/validators/poll_expiration_validator.rb | 13 ++++++ ...validator.rb => poll_options_validator.rb} | 8 +--- ...c.rb => poll_expiration_validator_spec.rb} | 16 +++++-- .../validators/poll_options_validator_spec.rb | 45 +++++++++++++++++++ 7 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 app/validators/poll_expiration_validator.rb rename app/validators/{poll_validator.rb => poll_options_validator.rb} (57%) rename spec/validators/{poll_validator_spec.rb => poll_expiration_validator_spec.rb} (63%) create mode 100644 spec/validators/poll_options_validator_spec.rb 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/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 57% rename from app/validators/poll_validator.rb rename to app/validators/poll_options_validator.rb index a32727796..0ac84f93f 100644 --- a/app/validators/poll_validator.rb +++ b/app/validators/poll_options_validator.rb @@ -1,19 +1,13 @@ # frozen_string_literal: true -class PollValidator < ActiveModel::Validator +class PollOptionsValidator < ActiveModel::Validator MAX_OPTIONS = 4 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/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 From 5a44db38accfc5b5853a0b5013356d7ce7b00594 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jan 2025 15:44:27 +0100 Subject: [PATCH 08/43] Fix incorrect signature after HTTP redirect (#33757) --- .../concerns/signature_verification.rb | 8 +-- app/lib/http_signature_draft.rb | 31 ++++++++++ app/lib/request.rb | 60 ++++++++++--------- spec/lib/request_spec.rb | 34 +++++++++-- 4 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 app/lib/http_signature_draft.rb 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/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..194f6d54a 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,18 @@ 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) }, + }, + socket_class: use_proxy? || @allow_local ? ProxySocket : Socket, + }.merge(options) @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 +97,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 +131,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 +146,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 +163,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/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! From b9f10c70b36acfc1014630dc2db7f79f0bba0054 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 29 Jan 2025 09:42:20 +0100 Subject: [PATCH 09/43] Fix missing timeout options in `Request` class (#33769) --- app/lib/request.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/request.rb b/app/lib/request.rb index 194f6d54a..330ab5956 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -81,8 +81,11 @@ class Request max_hops: 3, on_redirect: ->(response, request) { re_sign_on_redirect(response, request) }, }, + }.merge(options).merge( socket_class: use_proxy? || @allow_local ? ProxySocket : Socket, - }.merge(options) + timeout_class: PerOperationWithDeadline, + timeout_options: TIMEOUT + ) @options = @options.merge(proxy_url) if use_proxy? @headers = {} From 5ddbf42dae7bd119955c42f0874cb81dd2329ff7 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 3 Feb 2025 14:43:45 +0100 Subject: [PATCH 10/43] Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818) --- app/javascript/mastodon/features/emoji/emoji.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From 94155b48c4a7ca305a775b1e4b4b88178d80eeed Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 24 Feb 2025 11:02:12 +0100 Subject: [PATCH 11/43] Update dependency `net-imap` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2008d6390..cd9055e91 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) From 34519931729d6923e99ff3084b4d1c9c8f75930d Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 24 Feb 2025 11:02:34 +0100 Subject: [PATCH 12/43] Update dependency `rack` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index cd9055e91..976a197a6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -533,7 +533,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.11) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) From 629c30fdca986140751fad24fb86d69ba26b3659 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 24 Feb 2025 11:17:06 +0100 Subject: [PATCH 13/43] Add ruby 3.3 to test matrix --- .github/workflows/test-ruby.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 327ebc2db..4750e2a1e 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -313,6 +313,7 @@ jobs: - '3.0' - '3.1' - '.ruby-version' + - '3.3' steps: - uses: actions/checkout@v4 From dec5d55670550d517eb529100e4a08e8d7b1a0d1 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 24 Feb 2025 14:43:11 +0100 Subject: [PATCH 14/43] Update dependency `nokogiri` --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 7f7da8c57..3fdb121d7 100644 --- a/Gemfile +++ b/Gemfile @@ -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 976a197a6..e35798547 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -469,7 +469,7 @@ GEM net-protocol net-ssh (7.1.0) nio4r (2.7.4) - nokogiri (1.16.8) + nokogiri (1.17.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) nsa (0.3.0) @@ -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) From 34936ca889e188ca342d8877638ca5bb75a39cca Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Feb 2025 15:44:35 +0100 Subject: [PATCH 15/43] Merge commit from fork --- config/initializers/rack_attack.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 From bea340816de489feee31ab922e9549424b31f266 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Feb 2025 15:49:57 +0100 Subject: [PATCH 16/43] Merge commit from fork * Fix domain blocks/rationales being visible to unapproved/unconfirmed users * Fix domain blocks/rationales being visible to suspended users Co-authored-by: Claire * Allow moved users to view domain blocks * Add authorization specs for `/api/v1/instance/domain_blocks` spec * Fix tests * Fix incorrect test setup --------- Co-authored-by: Jeremy Kescher --- .../v1/instances/domain_blocks_controller.rb | 28 +++- .../api/v1/instances/domain_blocks_spec.rb | 137 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 spec/requests/api/v1/instances/domain_blocks_spec.rb 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/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 From 7accf9aa1214b7f6a2d4ea6838bfc57a7fd7bbad Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 26 Feb 2025 15:25:49 +0100 Subject: [PATCH 17/43] Update dependency `uri` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e35798547..0ca2d571e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) From 1a27e4e4cff390211a209760b6213ce4e3437d7a Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Feb 2025 11:34:07 +0100 Subject: [PATCH 18/43] Change HTML sanitization to remove unusable and unused `embed` tag (#34021) --- lib/sanitize_ext/sanitize_config.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 }, }, From 015858aef749b39e0a0ac662c8022ea0bf23b456 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Feb 2025 09:17:56 +0100 Subject: [PATCH 19/43] Bump version to v4.2.16 --- CHANGELOG.md | 17 +++++++++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a830ad7..ac70fd3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project will be documented in this file. +## [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/docker-compose.yml b/docker-compose.yml index d6fa5ebfe..13e1af9b7 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.16 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.16 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.16 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 9156b6916..37807dde5 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 15 + 16 end def default_prerelease From 1f9feb7c4cf57760023af9a48c109498fd591e5f Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Feb 2025 16:13:03 +0100 Subject: [PATCH 20/43] Drop compatibility with Ruby 3.0 --- .github/workflows/test-ruby.yml | 4 ---- Gemfile | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 4750e2a1e..5280f2cb7 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -118,7 +118,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' - '.ruby-version' ci_job: @@ -197,7 +196,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' - '.ruby-version' @@ -310,10 +308,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.0' - '3.1' - '.ruby-version' - - '3.3' steps: - uses: actions/checkout@v4 diff --git a/Gemfile b/Gemfile index 3fdb121d7..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' From 15d76984623756648b67ed64c8943269b72c3991 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Feb 2025 16:13:17 +0100 Subject: [PATCH 21/43] Update dependency `nokogiri` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0ca2d571e..13005edf7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -469,7 +469,7 @@ GEM net-protocol net-ssh (7.1.0) nio4r (2.7.4) - nokogiri (1.17.2) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) nsa (0.3.0) From 5d8c09194b38e4f237a668a095716906e7d2ad92 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Feb 2025 16:13:52 +0100 Subject: [PATCH 22/43] Bump version to v4.2.17 --- CHANGELOG.md | 10 ++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac70fd3d4..383dc75c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [4.2.17] - 2025-02-27 + +### Security + +- Update dependencies + +### Removed + +- Remove support for Ruby 3.0 + ## [4.2.16] - 2025-02-27 ### Security diff --git a/docker-compose.yml b/docker-compose.yml index 13e1af9b7..6b9ba4f1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: web: build: . - image: ghcr.io/mastodon/mastodon:v4.2.16 + image: ghcr.io/mastodon/mastodon:v4.2.17 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.16 + image: ghcr.io/mastodon/mastodon:v4.2.17 restart: always env_file: .env.production command: node ./streaming @@ -95,7 +95,7 @@ services: sidekiq: build: . - image: ghcr.io/mastodon/mastodon:v4.2.16 + image: ghcr.io/mastodon/mastodon:v4.2.17 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 37807dde5..87d372ae4 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 16 + 17 end def default_prerelease From 9a41c655827fe2df61bfec1c72cef2b920ad4f6a Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Feb 2025 08:46:57 +0100 Subject: [PATCH 23/43] Add Ruby 3.3 to Mastodon 4.2 test matrix --- .github/workflows/test-ruby.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 5280f2cb7..e07a0b605 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -120,6 +120,7 @@ jobs: ruby-version: - '3.1' - '.ruby-version' + - '3.3' ci_job: - 1 - 2 @@ -198,6 +199,7 @@ jobs: ruby-version: - '3.1' - '.ruby-version' + - '3.3' steps: - uses: actions/checkout@v4 @@ -310,6 +312,7 @@ jobs: ruby-version: - '3.1' - '.ruby-version' + - '3.3' steps: - uses: actions/checkout@v4 From b0ef64243d1c2993941ba535994579bf7f0e37d9 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 4 Jan 2024 11:55:00 -0500 Subject: [PATCH 24/43] Add sleep statement to nudge thread scheduler in request pool spec (#28596) --- spec/lib/request_pool_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb index f179e6ca9..bdb0859d7 100644 --- a/spec/lib/request_pool_spec.rb +++ b/spec/lib/request_pool_spec.rb @@ -33,11 +33,13 @@ describe RequestPool do subject - threads = Array.new(20) do |_i| + threads = Array.new(3) do Thread.new do - 20.times do + 2.times do subject.with('http://example.com') do |http_client| http_client.get('/').flush + # Nudge scheduler to yield and exercise the full pool + sleep(0) end end end From bd78330a2415020da9ee92e4137d73b999de1809 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 8 Jan 2024 13:29:05 +0100 Subject: [PATCH 25/43] Make request_pool_spec tests more robust (#28610) --- spec/lib/request_pool_spec.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb index bdb0859d7..a31d07832 100644 --- a/spec/lib/request_pool_spec.rb +++ b/spec/lib/request_pool_spec.rb @@ -33,14 +33,12 @@ describe RequestPool do subject - threads = Array.new(3) do + threads = Array.new(5) do Thread.new do - 2.times do - subject.with('http://example.com') do |http_client| - http_client.get('/').flush - # Nudge scheduler to yield and exercise the full pool - sleep(0) - 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 From 962587bfc8d16ff4b10261db9cba6fb309eb78f9 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Mar 2025 10:20:44 +0100 Subject: [PATCH 26/43] Fix streaming server not filtering unknown-language posts from public timelines (#33774) --- streaming/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streaming/index.js b/streaming/index.js index 3565ed278..e599b1904 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; } From bba17bc46720341a3b2215a4537b60393f03e667 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Mar 2025 10:32:07 +0100 Subject: [PATCH 27/43] Fix processing errors for some HEIF images from iOS 18 (#34086) --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) 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 \ From a9756884abe018ffa9dd404715ea400570b34034 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 6 Mar 2025 11:00:33 +0100 Subject: [PATCH 28/43] Change hashtag suggestion to prefer personal history capitalization (#34070) --- app/javascript/mastodon/reducers/compose.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 }) => { From 443871d913a3c2d459cc6ab779c6f3466de10007 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Mar 2025 10:34:24 +0100 Subject: [PATCH 29/43] Bump version to v4.2.18 --- CHANGELOG.md | 11 +++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 383dc75c6..a7e1798cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [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 diff --git a/docker-compose.yml b/docker-compose.yml index 6b9ba4f1e..fa1fa1c40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: web: build: . - image: ghcr.io/mastodon/mastodon:v4.2.17 + image: ghcr.io/mastodon/mastodon:v4.2.18 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.17 + image: ghcr.io/mastodon/mastodon:v4.2.18 restart: always env_file: .env.production command: node ./streaming @@ -95,7 +95,7 @@ services: sidekiq: build: . - image: ghcr.io/mastodon/mastodon:v4.2.17 + image: ghcr.io/mastodon/mastodon:v4.2.18 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 87d372ae4..55961d284 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 17 + 18 end def default_prerelease From eeca06f14e6f6fff421275888504ca7bd3b808cb Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 10 Mar 2025 15:27:43 +0100 Subject: [PATCH 30/43] Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126) --- lib/redis/namespace_extensions.rb | 4 ++++ 1 file changed, 4 insertions(+) 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 From 2126298cb1773a5c9a3caba7158fc9fb5e0425fa Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 13 Mar 2025 09:27:21 +0100 Subject: [PATCH 31/43] Update dependency `rack` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 13005edf7..2d489bf90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -533,7 +533,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.11) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) From 25c77617922827da8d30d18361975e6f9ef55c31 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 13 Mar 2025 09:36:47 +0100 Subject: [PATCH 32/43] Update dependency `omniauth-saml` --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2d489bf90..238e1bda9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) From a58a2b5fafb33e6ced71884419f0da5f1b398ae7 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Thu, 13 Mar 2025 13:32:44 +0100 Subject: [PATCH 33/43] Bump version to v4.2.19 --- CHANGELOG.md | 11 +++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e1798cd..acd91ceff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [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 diff --git a/docker-compose.yml b/docker-compose.yml index fa1fa1c40..bdcf84d83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: web: build: . - image: ghcr.io/mastodon/mastodon:v4.2.18 + image: ghcr.io/mastodon/mastodon:v4.2.19 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.18 + image: ghcr.io/mastodon/mastodon:v4.2.19 restart: always env_file: .env.production command: node ./streaming @@ -95,7 +95,7 @@ services: sidekiq: build: . - image: ghcr.io/mastodon/mastodon:v4.2.18 + image: ghcr.io/mastodon/mastodon:v4.2.19 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 55961d284..c74de47bd 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 18 + 19 end def default_prerelease From ed54baa6a29f2882a28ba54b732c788ec5d80ef1 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Mar 2025 11:06:53 +0100 Subject: [PATCH 34/43] Fix incorrect URL being used when cache busting (#34189) --- app/services/suspend_account_service.rb | 2 +- app/services/unsuspend_account_service.rb | 2 +- spec/services/suspend_account_service_spec.rb | 44 +++++++++++-------- 3 files changed, 28 insertions(+), 20 deletions(-) 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/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 From ec2b17860b1e2fb2d35b358e1c01e0bafda78b59 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 24 Mar 2025 10:59:05 +0100 Subject: [PATCH 35/43] Change user archive signed URL TTL from 10 seconds to 1 hour (#34254) --- app/controllers/backups_controller.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 From 39ba777fb9154a5f1beece6ff9540daca90a4700 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Mar 2025 11:35:33 +0100 Subject: [PATCH 36/43] Fix follower synchronization mechanism erroneously removing followers from multi-page collections (#34272) --- .../synchronize_followers_service.rb | 3 + .../synchronize_followers_service_spec.rb | 69 +++++++++++++------ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb index a9aab653c..33ecded62 100644 --- a/app/services/activitypub/synchronize_followers_service.rb +++ b/app/services/activitypub/synchronize_followers_service.rb @@ -57,6 +57,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService collection = fetch_collection(collection['first']) if collection['first'].present? return unless collection.is_a?(Hash) + # Abort if we'd have to paginate through more than one page of followers + return if collection['next'].present? + case collection['type'] when 'Collection', 'CollectionPage' as_array(collection['items']) diff --git a/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb index f62376ab9..dfccae3e0 100644 --- a/spec/services/activitypub/synchronize_followers_service_spec.rb +++ b/spec/services/activitypub/synchronize_followers_service_spec.rb @@ -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,30 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do it_behaves_like 'synchronizes followers' end + + context 'when the endpoint is a paginated Collection of actor URIs with a next page' 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_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'does not change followers' do + expect { subject.call(actor, collection_uri) } + .to_not(change { actor.followers.reload.reorder(id: :asc).pluck(:id) }) + end + end end end From 435389d896d64e09419353b57830b94e0c8a7b0a Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Mar 2025 11:36:22 +0100 Subject: [PATCH 37/43] Add support for paginating partial collections in `SynchronizeFollowersService` (#34277) --- app/models/account.rb | 1 + .../synchronize_followers_service.rb | 66 +++++++++----- .../synchronize_followers_service_spec.rb | 86 +++++++++++++++++-- 3 files changed, 128 insertions(+), 25 deletions(-) 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/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb index 33ecded62..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,21 +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) - # Abort if we'd have to paginate through more than one page of followers - return if collection['next'].present? + 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/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb index dfccae3e0..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 [ @@ -106,7 +106,76 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do it_behaves_like 'synchronizes followers' end - context 'when the endpoint is a paginated Collection of actor URIs with a next page' do + 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', @@ -122,12 +191,19 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do 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 'does not change followers' do - expect { subject.call(actor, collection_uri) } - .to_not(change { actor.followers.reload.reorder(id: :asc).pluck(:id) }) + 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 From a2ae0668012473b15011118c6144f9b72d9c07ff Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 12 Nov 2024 03:38:08 -0500 Subject: [PATCH 38/43] Define constants for sampling sizes in `AccountReachFinder` (#32805) --- app/lib/account_reach_finder.rb | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb index 481e25439..19464024a 100644 --- a/app/lib/account_reach_finder.rb +++ b/app/lib/account_reach_finder.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class AccountReachFinder + RECENT_LIMIT = 2_000 + STATUS_LIMIT = 200 + STATUS_SINCE = 2.days + def initialize(account) @account = account end @@ -20,13 +24,27 @@ 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(2000) + Account + .joins(:mentions) + .where(mentions: { status: recent_statuses }) + .inboxes + .take(RECENT_LIMIT) end def relay_inboxes Relay.enabled.pluck(:inbox_url) end + + def oldest_status_id + Mastodon::Snowflake + .id_at(STATUS_SINCE.ago, with_random: false) + end + + def recent_statuses + @account + .statuses + .recent + .where(id: oldest_status_id...) + .limit(STATUS_LIMIT) + end end From 47a5320e74b8264e61e0afc57d9a111b1b254bde Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Mar 2025 14:41:13 +0100 Subject: [PATCH 39/43] Change `AccountReachFinder` to consider statuses based on suspension date (#34291) --- app/lib/account_reach_finder.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb index 19464024a..f5e229c6a 100644 --- a/app/lib/account_reach_finder.rb +++ b/app/lib/account_reach_finder.rb @@ -37,7 +37,11 @@ class AccountReachFinder def oldest_status_id Mastodon::Snowflake - .id_at(STATUS_SINCE.ago, with_random: false) + .id_at(oldest_status_date, with_random: false) + end + + def oldest_status_date + @account.suspended? && @account.suspension_origin_local? ? @account.suspended_at - STATUS_SINCE : STATUS_SINCE.ago end def recent_statuses From cd2265767e6a840fcf06d7bc74dcb7e11f6cfe08 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 15 Oct 2024 09:18:57 -0400 Subject: [PATCH 40/43] Reduce factories (36 > 12) in `AccountReachFinder` spec (#32482) --- spec/lib/account_reach_finder_spec.rb | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/spec/lib/account_reach_finder_spec.rb b/spec/lib/account_reach_finder_spec.rb index e5d85656a..0c1d92b2d 100644 --- a/spec/lib/account_reach_finder_spec.rb +++ b/spec/lib/account_reach_finder_spec.rb @@ -38,16 +38,23 @@ 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 not_include(unrelated_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 end end From 483b4600b550ad5474decc99faaebb4d85b0c5ba Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Mar 2025 10:20:32 +0100 Subject: [PATCH 41/43] Change account suspensions to be federated to recently-followed accounts as well (#34294) --- app/lib/account_reach_finder.rb | 21 +++++++++++++++++--- spec/lib/account_reach_finder_spec.rb | 28 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb index f5e229c6a..4bf5c229a 100644 --- a/app/lib/account_reach_finder.rb +++ b/app/lib/account_reach_finder.rb @@ -10,7 +10,7 @@ class AccountReachFinder 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 @@ -31,16 +31,31 @@ class AccountReachFinder .take(RECENT_LIMIT) end + 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(oldest_status_date, with_random: false) + .id_at(recent_date_cutoff, with_random: false) end - def oldest_status_date + def recent_date_cutoff @account.suspended? && @account.suspension_origin_local? ? @account.suspended_at - STATUS_SINCE : STATUS_SINCE.ago end diff --git a/spec/lib/account_reach_finder_spec.rb b/spec/lib/account_reach_finder_spec.rb index 0c1d92b2d..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) @@ -44,7 +59,10 @@ RSpec.describe AccountReachFinder 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 def follower_inbox_urls @@ -56,5 +74,15 @@ RSpec.describe AccountReachFinder do [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 From 91ef24d0e3d6415e6aa91b3ee6066cf56b4703e1 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Mar 2025 17:12:32 +0100 Subject: [PATCH 42/43] Add delay to profile updates to debounce them (#34137) --- .../api/v1/accounts/credentials_controller.rb | 2 +- .../api/v1/profile/avatars_controller.rb | 2 +- .../api/v1/profile/headers_controller.rb | 2 +- .../settings/pictures_controller.rb | 2 +- .../settings/privacy_controller.rb | 2 +- .../settings/profiles_controller.rb | 2 +- .../activitypub/update_distribution_worker.rb | 2 + .../accounts/credentials_controller_spec.rb | 4 +- .../settings/privacy_controller_spec.rb | 70 +++++++++++++++++++ .../settings/profiles_controller_spec.rb | 8 +-- .../api/v1/accounts/credentials_spec.rb | 41 ++++++++++- spec/requests/api/v1/profiles_spec.rb | 16 +---- 12 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 spec/controllers/settings/privacy_controller_spec.rb 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/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/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/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/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/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/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 From 6feb39c46dd6aa251a4a546b7551bd830f3a1a7b Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 2 Apr 2025 09:14:25 +0200 Subject: [PATCH 43/43] Bump version to v4.2.20 (#34329) --- CHANGELOG.md | 17 +++++++++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acd91ceff..5fd009f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ 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 diff --git a/docker-compose.yml b/docker-compose.yml index bdcf84d83..84ec231d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: web: build: . - image: ghcr.io/mastodon/mastodon:v4.2.19 + 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.19 + 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.19 + 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 c74de47bd..97a92ef74 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 19 + 20 end def default_prerelease