45 Commits 1e58069321 ... a83c1da548

Author SHA1 Message Date
  jops a83c1da548 Merge tag 'v3.5.17' into stable-3.5-bida 3 months ago
  Claire b1ed009c65 Merge pull request from GHSA-3fjr-858r-92rw 3 months ago
  Claire 35f21191ee Bump version to v3.5.16 5 months ago
  Claire 2ffce0d5f7 Fix processing LDSigned activities from actors with unknown public keys (#27474) 6 months ago
  Claire 688defd60d Change GIF max matrix size error to explicitly mention GIF files (#27927) 5 months ago
  Jonathan de Jong d9b05f6860 Have `Follow` activities bypass availability (#27586) 6 months ago
  Claire f3fd8d8695 Clamp dates when serializing to Elasticsearch API (#28081) 5 months ago
  Claire 49693fe42f Fix incoming status creation date not being restricted to standard ISO8601 (#27655) 6 months ago
  Claire 16262f815d Fix posts from force-sensitized accounts being able to trend (#27620) 6 months ago
  Claire d4e0a12b27 Change Content-Security-Policy to be tighter on media paths (#26889) 6 months ago
  Claire db59d8486b Bump version to v3.5.15 6 months ago
  Matt Jankowski 7fb3ee0bc6 Dont match mention in url query string (#25656) 7 months ago
  David Aaron 9bd027823d Change min age of backup policy from 1 week to 6 days (#27200) 7 months ago
  Jakob Gillich 57d4d46050 Fix importer returning negative row estimates (#27258) 7 months ago
  Claire c91116f780 Fix filtering audit log for entries about disabling 2FA (#27186) 7 months ago
  Essem f45b5f5006 Properly remove tIME chunk from PNG uploads (#27111) 7 months ago
  Claire 47441e51f3 Fix crash when filtering for “dormant” relationships (#27306) 7 months ago
  Claire af02650322 Fix inefficient queries in “Follows and followers” as well as several admin pages (#27116) 7 months ago
  Claire 75346a71f7 Bump version to v3.5.14 7 months ago
  Claire 49af3e26dc Fix moderator rights inconsistencies (#26729) 8 months ago
  Claire 412c3e13ec Fix crash when encountering invalid URL (#26814) 8 months ago
  Claire 31c5e63a58 Fix cached posts including stale stats (#26409) 8 months ago
  Nicolai Søborg e8eeb746ac Fix `frame_rate` for videos where `ffprobe` reports 0/0 (#26500) 8 months ago
  yufushiro 0158c31c02 Fix unexpected audio stream transcoding when uploaded video is eligible to passthrough (#26608) 8 months ago
  Claire 9deb178126 Merge pull request from GHSA-v3xf-c9qf-j667 7 months ago
  Claire 8e6fe19225 Change Dockerfile to upgrade packages when building (#26931) 7 months ago
  Claire 4eb709ea7e Update actions for stable-3.5 (#26804) 8 months ago
  Claire 86a31fc019 Fix Dockerfile installing incompatible npm version (#26803) 8 months ago
  Claire 16e47e1aae Bump version to v3.5.13 8 months ago
  Emelia Smith dcffd6b3d7 Allow reports with long comments from remote instances, but truncate (#25028) 11 months ago
  Daniel M Brasil 8de0f7e198 Fix `/api/v1/timelines/tag/:hashtag` allowing for unauthenticated access when public preview is disabled (#26237) 8 months ago
  Claire e37551421e Fix blocking subdomains of an already-blocked domain (#26392) 8 months ago
  Claire 2e0eab9d18 Change text extraction in `PlainTextFormatter` to be faster (#26727) 8 months ago
  Claire ce75c175cd Backport container build changes to the stable-3.5 branch (#26742) 8 months ago
  Claire a3d31ffc1e Bump version to v3.5.12 9 months ago
  Emelia Smith 50f4af28b0 Fix: Streaming server memory leak in HTTP EventSource cleanup (#26228) 9 months ago
  Claire e655b35d7e Fix incorrect connect timeout in outgoing requests (#26116) 9 months ago
  Claire 80c00f4aa5 Bump version to v3.5.11 9 months ago
  Claire 1a0192537d Add check preventing Sidekiq workers from running with Makara configured (#25850) 9 months ago
  Claire 668cd00e13 Fix testsuite failure introduced in last release 9 months ago
  Claire 0bd52de492 Fix CSP headers being unintendedly wide (#26105) 9 months ago
  Claire ced65ffbb4 Change request timeout handling to use a longer deadline (#26055) 9 months ago
  Claire 6398fc0b66 Fix moderation interface for remote instances with a .zip TLD (#25885) 9 months ago
  Claire 7709bbba65 Fix remote accounts being possibly persisted to database with incomplete protocol values (#25886) 9 months ago
  Michael Stanclift 4f6d121b24 Fix trending publishers table not rendering correctly on narrow screens (#25945) 9 months ago
68 changed files with 736 additions and 450 deletions
  1. 0 211
      .circleci/config.yml
  2. 92 0
      .github/workflows/build-container-image.yml
  3. 0 64
      .github/workflows/build-image.yml
  4. 27 0
      .github/workflows/build-releases.yml
  5. 15 0
      .github/workflows/test-image-build.yml
  6. 92 0
      CHANGELOG.md
  7. 2 1
      Dockerfile
  8. 7 8
      SECURITY.md
  9. 3 1
      app/chewy/accounts_index.rb
  10. 14 0
      app/chewy/concerns/datetime_clamping_concern.rb
  11. 3 1
      app/chewy/tags_index.rb
  12. 1 1
      app/controllers/admin/domain_blocks_controller.rb
  13. 5 0
      app/controllers/api/v1/timelines/tag_controller.rb
  14. 1 1
      app/controllers/concerns/signature_verification.rb
  15. 2 2
      app/helpers/jsonld_helper.rb
  16. 0 0
      app/lib/account_statuses_filter.rb
  17. 2 1
      app/lib/activitypub/activity.rb
  18. 5 1
      app/lib/activitypub/activity/flag.rb
  19. 5 5
      app/lib/activitypub/linked_data_signature.rb
  20. 2 1
      app/lib/activitypub/parser/status_parser.rb
  21. 4 0
      app/lib/activitypub/tag_manager.rb
  22. 9 0
      app/lib/admin/account_statuses_filter.rb
  23. 3 1
      app/lib/importer/base_importer.rb
  24. 5 4
      app/lib/plain_text_formatter.rb
  25. 19 9
      app/lib/request.rb
  26. 5 3
      app/lib/tag_manager.rb
  27. 3 0
      app/lib/video_metadata_extractor.rb
  28. 5 5
      app/models/account.rb
  29. 1 1
      app/models/admin/action_log_filter.rb
  30. 1 1
      app/models/admin/status_batch_action.rb
  31. 6 2
      app/models/concerns/attachmentable.rb
  32. 1 1
      app/models/media_attachment.rb
  33. 5 5
      app/models/relationship_filter.rb
  34. 4 5
      app/models/report.rb
  35. 12 0
      app/models/status.rb
  36. 1 1
      app/models/trends/statuses.rb
  37. 1 1
      app/policies/backup_policy.rb
  38. 3 3
      app/services/activitypub/fetch_remote_account_service.rb
  39. 2 15
      app/services/activitypub/fetch_remote_key_service.rb
  40. 4 4
      app/services/activitypub/fetch_remote_status_service.rb
  41. 4 1
      app/services/activitypub/process_account_service.rb
  42. 9 1
      app/services/fetch_resource_service.rb
  43. 1 1
      app/services/follow_service.rb
  44. 1 1
      app/views/admin/trends/links/preview_card_providers/index.html.haml
  45. 3 2
      app/workers/activitypub/delivery_worker.rb
  46. 5 1
      config/initializers/content_security_policy.rb
  47. 5 0
      config/initializers/sidekiq.rb
  48. 1 1
      config/routes.rb
  49. 3 3
      docker-compose.yml
  50. 1 1
      lib/mastodon/version.rb
  51. 8 6
      lib/paperclip/transcoder.rb
  52. 16 4
      spec/controllers/admin/statuses_controller_spec.rb
  53. 53 18
      spec/controllers/api/v1/timelines/tag_controller_spec.rb
  54. 22 2
      spec/controllers/concerns/cache_concern_spec.rb
  55. 6 4
      spec/fabricators/account_stat_fabricator.rb
  56. 44 6
      spec/lib/activitypub/activity/create_spec.rb
  57. 31 0
      spec/lib/activitypub/activity/flag_spec.rb
  58. 34 0
      spec/lib/activitypub/linked_data_signature_spec.rb
  59. 1 1
      spec/models/account_spec.rb
  60. 48 20
      spec/models/relationship_filter_spec.rb
  61. 9 2
      spec/models/report_spec.rb
  62. 1 1
      spec/requests/api/v2/media_spec.rb
  63. 27 0
      spec/requests/content_security_policy_spec.rb
  64. 1 1
      spec/services/activitypub/fetch_remote_account_service_spec.rb
  65. 5 5
      spec/services/fetch_resource_service_spec.rb
  66. 8 0
      spec/services/report_service_spec.rb
  67. 1 0
      spec/services/resolve_url_service_spec.rb
  68. 16 9
      streaming/index.js

+ 0 - 211
.circleci/config.yml

@@ -1,211 +0,0 @@
-version: 2.1
-
-orbs:
-  ruby: circleci/ruby@1.4.1
-  node: circleci/node@5.0.1
-
-executors:
-  default:
-    parameters:
-      ruby-version:
-        type: string
-    docker:
-      - image: cimg/ruby:<< parameters.ruby-version >>
-        environment:
-          BUNDLE_JOBS: 3
-          BUNDLE_RETRY: 3
-          CONTINUOUS_INTEGRATION: true
-          DB_HOST: localhost
-          DB_USER: root
-          DISABLE_SIMPLECOV: true
-          RAILS_ENV: test
-      - image: cimg/postgres:14.0
-        environment:
-          POSTGRES_USER: root
-          POSTGRES_HOST_AUTH_METHOD: trust
-      - image: cimg/redis:6.2
-
-commands:
-  install-system-dependencies:
-    steps:
-      - run:
-          name: Install system dependencies
-          command: |
-            sudo apt-get update
-            sudo apt-get install -y libicu-dev libidn11-dev
-  install-ruby-dependencies:
-    parameters:
-      ruby-version:
-        type: string
-    steps:
-      - run:
-          command: |
-            bundle config clean 'true'
-            bundle config frozen 'true'
-            bundle config without 'development production'
-          name: Set bundler settings
-      - ruby/install-deps:
-          bundler-version: '2.3.8'
-          key: ruby<< parameters.ruby-version >>-gems-v1
-  wait-db:
-    steps:
-      - run:
-          command: dockerize -wait tcp://localhost:5432 -wait tcp://localhost:6379 -timeout 1m
-          name: Wait for PostgreSQL and Redis
-
-jobs:
-  build:
-    docker:
-      - image: cimg/ruby:3.0-node
-        environment:
-          RAILS_ENV: test
-    steps:
-      - checkout
-      - install-system-dependencies
-      - install-ruby-dependencies:
-          ruby-version: '3.0'
-      - node/install-packages:
-          cache-version: v1
-          pkg-manager: yarn
-      - run:
-          command: |
-            export NODE_OPTIONS=--openssl-legacy-provider
-            ./bin/rails assets:precompile
-          name: Precompile assets
-      - persist_to_workspace:
-          paths:
-            - public/assets
-            - public/packs-test
-          root: .
-
-  test:
-    parameters:
-      ruby-version:
-        type: string
-    executor:
-      name: default
-      ruby-version: << parameters.ruby-version >>
-    environment:
-      ALLOW_NOPAM: true
-      PAM_ENABLED: true
-      PAM_DEFAULT_SERVICE: pam_test
-      PAM_CONTROLLED_SERVICE: pam_test_controlled
-    parallelism: 4
-    steps:
-      - checkout
-      - install-system-dependencies
-      - run:
-          command: sudo apt-get install -y ffmpeg imagemagick libpam-dev
-          name: Install additional system dependencies
-      - run:
-          command: bundle config with 'pam_authentication'
-          name: Enable PAM authentication
-      - install-ruby-dependencies:
-          ruby-version: << parameters.ruby-version >>
-      - attach_workspace:
-          at: .
-      - wait-db
-      - run:
-          command: ./bin/rails db:create db:schema:load db:seed
-          name: Load database schema
-      - ruby/rspec-test
-
-  test-migrations:
-    executor:
-      name: default
-      ruby-version: '3.0'
-    steps:
-      - checkout
-      - install-system-dependencies
-      - install-ruby-dependencies:
-          ruby-version: '3.0'
-      - wait-db
-      - run:
-          command: ./bin/rails db:create
-          name: Create database
-      - run:
-          command: ./bin/rails db:migrate VERSION=20171010025614
-          name: Run migrations up to v2.0.0
-      - run:
-          command: ./bin/rails tests:migrations:populate_v2
-          name: Populate database with test data
-      - run:
-          command: ./bin/rails db:migrate VERSION=20180514140000
-          name: Run migrations up to v2.4.0
-      - run:
-          command: ./bin/rails tests:migrations:populate_v2_4
-          name: Populate database with test data
-      - run:
-          command: ./bin/rails db:migrate
-          name: Run all remaining migrations
-      - run:
-          command: ./bin/rails tests:migrations:check_database
-          name: Check migration result
-
-  test-two-step-migrations:
-    executor:
-      name: default
-      ruby-version: '3.0'
-    steps:
-      - checkout
-      - install-system-dependencies
-      - install-ruby-dependencies:
-          ruby-version: '3.0'
-      - wait-db
-      - run:
-          command: ./bin/rails db:create
-          name: Create database
-      - run:
-          command: ./bin/rails db:migrate VERSION=20171010025614
-          name: Run migrations up to v2.0.0
-      - run:
-          command: ./bin/rails tests:migrations:populate_v2
-          name: Populate database with test data
-      - run:
-          command: ./bin/rails db:migrate VERSION=20180514140000
-          name: Run pre-deployment migrations up to v2.4.0
-          environment:
-            SKIP_POST_DEPLOYMENT_MIGRATIONS: true
-      - run:
-          command: ./bin/rails tests:migrations:populate_v2_4
-          name: Populate database with test data
-      - run:
-          command: ./bin/rails db:migrate
-          name: Run all pre-deployment migrations
-          environment:
-            SKIP_POST_DEPLOYMENT_MIGRATIONS: true
-      - run:
-          command: ./bin/rails db:migrate
-          name: Run all post-deployment remaining migrations
-      - run:
-          command: ./bin/rails tests:migrations:check_database
-          name: Check migration result
-
-workflows:
-  version: 2
-  build-and-test:
-    jobs:
-      - build
-      - test:
-          matrix:
-            parameters:
-              ruby-version:
-                - '2.7'
-                - '3.0'
-          name: test-ruby<< matrix.ruby-version >>
-          requires:
-            - build
-      - test-migrations:
-          requires:
-            - build
-      - test-two-step-migrations:
-          requires:
-            - build
-      - node/run:
-          cache-version: v1
-          name: test-webui
-          pkg-manager: yarn
-          requires:
-            - build
-          version: lts
-          yarn-run: test:jest

+ 92 - 0
.github/workflows/build-container-image.yml

@@ -0,0 +1,92 @@
+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
+      flavor:
+        type: string
+      tags:
+        type: string
+      labels:
+        type: string
+
+jobs:
+  build-image:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - uses: docker/setup-qemu-action@v2
+        if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder
+
+      - 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')
+        uses: docker/login-action@v2
+        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@v2
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - uses: docker/metadata-action@v4
+        id: meta
+        if: ${{ inputs.push_to_images != '' }}
+        with:
+          images: ${{ inputs.push_to_images }}
+          flavor: ${{ inputs.flavor }}
+          tags: ${{ inputs.tags }}
+          labels: ${{ inputs.labels }}
+
+      - uses: docker/build-push-action@v4
+        with:
+          context: .
+          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' || '' }}

+ 0 - 64
.github/workflows/build-image.yml

@@ -1,64 +0,0 @@
-name: Build container image
-on:
-  workflow_dispatch:
-  push:
-    branches:
-      - 'main'
-    tags:
-      - '*'
-  pull_request:
-    paths:
-      - .github/workflows/build-image.yml
-      - Dockerfile
-
-permissions:
-  contents: read
-  packages: write
-
-jobs:
-  build-image:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v3
-      - uses: docker/setup-qemu-action@v2
-      - uses: docker/setup-buildx-action@v2
-
-      - name: Log in to Docker Hub
-        uses: docker/login-action@v2
-        with:
-          username: ${{ secrets.DOCKERHUB_USERNAME }}
-          password: ${{ secrets.DOCKERHUB_TOKEN }}
-        if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
-
-      - name: Log in to the Github Container registry
-        uses: docker/login-action@v2
-        with:
-          registry: ghcr.io
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-        if: github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request'
-
-      - uses: docker/metadata-action@v4
-        id: meta
-        with:
-          images: |
-            tootsuite/mastodon
-            ghcr.io/mastodon/mastodon
-          flavor: |
-            latest=auto
-          tags: |
-            type=edge,branch=main
-            type=match,pattern=v(.*),group=0
-            type=ref,event=pr
-
-      - uses: docker/build-push-action@v4
-        with:
-          context: .
-          platforms: linux/amd64,linux/arm64
-          provenance: false
-          builder: ${{ steps.buildx.outputs.name }}
-          push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }}
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-          cache-from: type=gha
-          cache-to: type=gha,mode=max

+ 27 - 0
.github/workflows/build-releases.yml

@@ -0,0 +1,27 @@
+name: Build container release images
+on:
+  push:
+    tags:
+      - '*'
+
+permissions:
+  contents: read
+  packages: write
+
+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
+      # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages
+      cache: false
+      flavor: |
+        latest=false
+      tags: |
+        type=pep440,pattern={{raw}}
+        type=pep440,pattern=v{{major}}.{{minor}}
+    secrets: inherit

+ 15 - 0
.github/workflows/test-image-build.yml

@@ -0,0 +1,15 @@
+name: Test container image build
+on:
+  pull_request:
+permissions:
+  contents: read
+
+jobs:
+  build-image:
+    concurrency:
+      group: ${{ github.workflow }}-${{ github.ref }}
+      cancel-in-progress: true
+
+    uses: ./.github/workflows/build-container-image.yml
+    with:
+      platforms: linux/amd64 # Testing only on native platform so it is performant

+ 92 - 0
CHANGELOG.md

@@ -3,6 +3,98 @@ Changelog
 
 All notable changes to this project will be documented in this file.
 
+## End of life notice
+
+**The 3.5.x branch has reached its end of life and will not receive any further update.**
+This means that no security fix will be made available for this branch after this date, and you will need to update to a more recent version (such as the 4.2.x branch) to receive security fixes.
+
+## [3.5.17] - 2024-02-01
+
+### Security
+
+- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw))
+
+## [3.5.16] - 2023-12-04
+
+### Changed
+
+- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927))
+- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586))
+- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889))
+
+### Fixed
+
+- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081))
+- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620))
+- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474))
+
+## [3.5.15] - 2023-10-10
+
+### Changed
+
+- Change user archive export allowed period from 7 days to 6 days ([suddjian](https://github.com/mastodon/mastodon/pull/27200))
+
+### Fixed
+
+- Fix mentions being matched in some URL query strings ([mjankowski](https://github.com/mastodon/mastodon/pull/25656))
+- Fix importer returning negative row estimates ([jgillich](https://github.com/mastodon/mastodon/pull/27258))
+- Fix filtering audit log for entries about disabling 2FA ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27186))
+- Fix tIME chunk not being properly removed from PNG uploads ([TheEssem](https://github.com/mastodon/mastodon/pull/27111))
+- Fix inefficient queries in “Follows and followers” as well as several admin pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27116), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27306))
+
+## [3.5.14] - 2023-09-19
+
+### Fixed
+
+- Fix moderator rights inconsistencies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26729))
+- Fix crash when encountering invalid URL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26814))
+- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409))
+- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500))
+- Fix unexpected audio stream transcoding when uploaded video is eligible to passthrough ([yufushiro](https://github.com/mastodon/mastodon/pull/26608))
+
+### Security
+
+- Fix incorrect domain name normalization (CVE-2023-42451)
+
+## [3.5.13] - 2023-09-05
+
+### Changed
+
+- Change remote report processing to accept reports with long comments, but truncate them ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25028))
+
+### Fixed
+
+- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392))
+- Fix `/api/v1/timelines/tag/:hashtag` allowing for unauthenticated access when public preview is disabled ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26237))
+- Fix inefficiencies in `PlainTextFormatter` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26727))
+
+## [3.5.12] - 2023-07-31
+
+### Fixed
+
+- Fix memory leak in streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26228))
+- Fix incorrect connect timeout in outgoing requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26116))
+
+## [3.5.11] - 2023-07-21
+
+### Added
+
+- Add check preventing Sidekiq workers from running with Makara configured ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25850))
+
+### Changed
+
+- Change request timeout handling to use a longer deadline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26055))
+
+### Fixed
+
+- Fix moderation interface for remote instances with a .zip TLD ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25886))
+- Fix remote accounts being possibly persisted to database with incomplete protocol values ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25886))
+- Fix trending publishers table not rendering correctly on narrow screens ([vmstan](https://github.com/mastodon/mastodon/pull/25945))
+
+### Security
+
+- Fix CSP headers being unintentionally wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26105))
+
 ## [3.5.10] - 2023-07-07
 
 ### Fixed

+ 2 - 1
Dockerfile

@@ -19,6 +19,7 @@ RUN ARCH= && \
   esac && \
     echo "Etc/UTC" > /etc/localtime && \
 	apt-get update && \
+	apt-get -yq dist-upgrade && \
 	apt-get install -y --no-install-recommends ca-certificates wget python apt-utils && \
 	cd ~ && \
 	wget -q https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \
@@ -46,7 +47,7 @@ RUN apt-get update && \
 
 ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin"
 
-RUN npm install -g npm@latest && \
+RUN npm install -g npm@9 && \
 	npm install -g yarn && \
 	gem install bundler && \
 	apt-get update && \

+ 7 - 8
SECURITY.md

@@ -10,11 +10,10 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
 
 ## Supported Versions
 
-| Version | Supported          |
-| ------- | ------------------ |
-| 3.5.x   | Yes                |
-| 3.4.x   | Yes                |
-| 3.3.x   | No                 |
-| < 3.3   | No                 |
-
-[bug-bounty]: https://app.intigriti.com/programs/mastodon/mastodonio/detail
+| Version | Supported        |
+| ------- | ---------------- |
+| 4.2.x   | Yes              |
+| 4.1.x   | Yes              |
+| 4.0.x   | No               |
+| 3.5.x   | No               |
+| < 3.5   | No               |

+ 3 - 1
app/chewy/accounts_index.rb

@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class AccountsIndex < Chewy::Index
+  include DatetimeClampingConcern
+
   settings index: { refresh_interval: '30s' }, analysis: {
     analyzer: {
       content: {
@@ -38,6 +40,6 @@ class AccountsIndex < Chewy::Index
 
     field :following_count, type: 'long', value: ->(account) { account.following_count }
     field :followers_count, type: 'long', value: ->(account) { account.followers_count }
-    field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
+    field :last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) }
   end
 end

+ 14 - 0
app/chewy/concerns/datetime_clamping_concern.rb

@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module DatetimeClampingConcern
+  extend ActiveSupport::Concern
+
+  MIN_ISO8601_DATETIME = '0000-01-01T00:00:00Z'.to_datetime.freeze
+  MAX_ISO8601_DATETIME = '9999-12-31T23:59:59Z'.to_datetime.freeze
+
+  class_methods do
+    def clamp_date(datetime)
+      datetime.clamp(MIN_ISO8601_DATETIME, MAX_ISO8601_DATETIME)
+    end
+  end
+end

+ 3 - 1
app/chewy/tags_index.rb

@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class TagsIndex < Chewy::Index
+  include DatetimeClampingConcern
+
   settings index: { refresh_interval: '30s' }, analysis: {
     analyzer: {
       content: {
@@ -36,6 +38,6 @@ class TagsIndex < Chewy::Index
 
     field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
     field :usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }
-    field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
+    field :last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) }
   end
 end

+ 1 - 1
app/controllers/admin/domain_blocks_controller.rb

@@ -25,7 +25,7 @@ module Admin
         @domain_block.errors.delete(:domain)
         render :new
       else
-        if existing_domain_block.present?
+        if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain.strip)
           @domain_block = existing_domain_block
           @domain_block.update(resource_params)
         end

+ 5 - 0
app/controllers/api/v1/timelines/tag_controller.rb

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Api::V1::Timelines::TagController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
   before_action :load_tag
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
@@ -11,6 +12,10 @@ class Api::V1::Timelines::TagController < Api::BaseController
 
   private
 
+  def require_auth?
+    !Setting.timeline_preview
+  end
+
   def load_tag
     @tag = Tag.find_normalized(params[:id])
   end

+ 1 - 1
app/controllers/concerns/signature_verification.rb

@@ -219,7 +219,7 @@ module SignatureVerification
       stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) }
     elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
       account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
-      account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) }
+      account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id) }
       account
     end
   rescue Mastodon::HostValidationError

+ 2 - 2
app/helpers/jsonld_helper.rb

@@ -157,8 +157,8 @@ module JsonLdHelper
     end
   end
 
-  def fetch_resource(uri, id, on_behalf_of = nil)
-    unless id
+  def fetch_resource(uri, id_is_known, on_behalf_of = nil)
+    unless id_is_known
       json = fetch_resource_without_id_validation(uri, on_behalf_of)
 
       return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])

+ 0 - 0
app/models/account_statuses_filter.rb → app/lib/account_statuses_filter.rb


+ 2 - 1
app/lib/activitypub/activity.rb

@@ -153,7 +153,8 @@ class ActivityPub::Activity
   def fetch_remote_original_status
     if object_uri.start_with?('http')
       return if ActivityPub::TagManager.instance.local_uri?(object_uri)
-      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
+
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
     elsif @object['url'].present?
       ::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
     end

+ 5 - 1
app/lib/activitypub/activity/flag.rb

@@ -16,7 +16,7 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
         @account,
         target_account,
         status_ids: target_statuses.nil? ? [] : target_statuses.map(&:id),
-        comment: @json['content'] || '',
+        comment: report_comment,
         uri: report_uri
       )
     end
@@ -35,4 +35,8 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
   def report_uri
     @json['id'] unless @json['id'].nil? || invalid_origin?(@json['id'])
   end
+
+  def report_comment
+    (@json['content'] || '')[0...5000]
+  end
 end

+ 5 - 5
app/lib/activitypub/linked_data_signature.rb

@@ -18,8 +18,8 @@ class ActivityPub::LinkedDataSignature
 
     return unless type == 'RsaSignature2017'
 
-    creator   = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
-    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false)
+    creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
+    creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank?
 
     return if creator.nil?
 
@@ -27,9 +27,9 @@ class ActivityPub::LinkedDataSignature
     document_hash  = hash(@json.without('signature'))
     to_be_verified = options_hash + document_hash
 
-    if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified)
-      creator
-    end
+    creator if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified)
+  rescue OpenSSL::PKey::RSAError
+    false
   end
 
   def sign!(creator, sign_with: nil)

+ 2 - 1
app/lib/activitypub/parser/status_parser.rb

@@ -53,7 +53,8 @@ class ActivityPub::Parser::StatusParser
   end
 
   def created_at
-    @object['published']&.to_datetime
+    datetime = @object['published']&.to_datetime
+    datetime if datetime.present? && (0..9999).cover?(datetime.year)
   rescue ArgumentError
     nil
   end

+ 4 - 0
app/lib/activitypub/tag_manager.rb

@@ -27,6 +27,8 @@ class ActivityPub::TagManager
     when :note, :comment, :activity
       return activity_account_status_url(target.account, target) if target.reblog?
       short_account_status_url(target.account, target)
+    when :flag
+      target.uri
     end
   end
 
@@ -41,6 +43,8 @@ class ActivityPub::TagManager
       account_status_url(target.account, target)
     when :emoji
       emoji_url(target)
+    when :flag
+      target.uri
     end
   end
 

+ 9 - 0
app/lib/admin/account_statuses_filter.rb

@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Admin::AccountStatusesFilter < AccountStatusesFilter
+  private
+
+  def blocked?
+    false
+  end
+end

+ 3 - 1
app/lib/importer/base_importer.rb

@@ -34,7 +34,9 @@ class Importer::BaseImporter
   # Estimate the amount of documents that would be indexed. Not exact!
   # @returns [Integer]
   def estimate!
-    ActiveRecord::Base.connection_pool.with_connection { |connection| connection.select_one("SELECT reltuples AS estimate FROM pg_class WHERE relname = '#{index.adapter.target.table_name}'")['estimate'].to_i }
+    reltuples = ActiveRecord::Base.connection_pool.with_connection { |connection| connection.select_one("SELECT reltuples FROM pg_class WHERE relname = '#{index.adapter.target.table_name}'")['reltuples'].to_i }
+    # If the table has never yet been vacuumed or analyzed, reltuples contains -1
+    [reltuples, 0].max
   end
 
   # Import data from the database into the index

+ 5 - 4
app/lib/plain_text_formatter.rb

@@ -1,9 +1,7 @@
 # frozen_string_literal: true
 
 class PlainTextFormatter
-  include ActionView::Helpers::TextHelper
-
-  NEWLINE_TAGS_RE = /(<br \/>|<br>|<\/p>)+/.freeze
+  NEWLINE_TAGS_RE = %r{(<br />|<br>|</p>)+}
 
   attr_reader :text, :local
 
@@ -18,7 +16,10 @@ class PlainTextFormatter
     if local?
       text
     else
-      strip_tags(insert_newlines).chomp
+      node = Nokogiri::HTML.fragment(insert_newlines)
+      # Elements that are entirely removed with our Sanitize config
+      node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove
+      node.text.chomp
     end
   end
 

+ 19 - 9
app/lib/request.rb

@@ -4,14 +4,22 @@ require 'ipaddr'
 require 'socket'
 require 'resolv'
 
-# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
+# Use our own timeout class to avoid using HTTP.rb's timeout block
 # around the Socket#open method, since we use our own timeout blocks inside
 # that method
 #
 # Also changes how the read timeout behaves so that it is cumulative (closer
 # to HTTP::Timeout::Global, but still having distinct timeouts for other
 # operation types)
-class HTTP::Timeout::PerOperation
+class PerOperationWithDeadline < HTTP::Timeout::PerOperation
+  READ_DEADLINE = 30
+
+  def initialize(*args)
+    super
+
+    @read_deadline = options.fetch(:read_deadline, READ_DEADLINE)
+  end
+
   def connect(socket_class, host, port, nodelay = false)
     @socket = socket_class.open(host, port)
     @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
@@ -24,7 +32,7 @@ class HTTP::Timeout::PerOperation
 
   # Read data from the socket
   def readpartial(size, buffer = nil)
-    @deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
+    @deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_deadline
 
     timeout = false
     loop do
@@ -33,7 +41,8 @@ class HTTP::Timeout::PerOperation
       return :eof if result.nil?
 
       remaining_time = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
-      raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout || remaining_time <= 0
+      raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout
+      raise HTTP::TimeoutError, "Read timed out after a total of #{@read_deadline} seconds" if remaining_time <= 0
       return result if result != :wait_readable
 
       # marking the socket for timeout. Why is this not being raised immediately?
@@ -46,7 +55,7 @@ class HTTP::Timeout::PerOperation
       # timeout. Else, the first timeout was a proper timeout.
       # This hack has to be done because io/wait#wait_readable doesn't provide a value for when
       # the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
-      timeout = true unless @socket.to_io.wait_readable(remaining_time)
+      timeout = true unless @socket.to_io.wait_readable([remaining_time, @read_timeout].min)
     end
   end
 end
@@ -57,7 +66,7 @@ class Request
   # 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
-  TIMEOUT = { connect: 5, read: 10, write: 10 }.freeze
+  TIMEOUT = { connect_timeout: 5, read_timeout: 10, write_timeout: 10, read_deadline: 30 }.freeze
 
   include RoutingHelper
 
@@ -68,6 +77,7 @@ class Request
     @url         = Addressable::URI.parse(url).normalize
     @http_client = options.delete(:http_client)
     @options     = options.merge(socket_class: use_proxy? ? ProxySocket : Socket)
+    @options     = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
     @options     = @options.merge(Rails.configuration.x.http_client_proxy) if use_proxy?
     @headers     = {}
 
@@ -131,7 +141,7 @@ class Request
     end
 
     def http_client
-      HTTP.use(:auto_inflate).timeout(TIMEOUT.dup).follow(max_hops: 3)
+      HTTP.use(:auto_inflate).follow(max_hops: 3)
     end
   end
 
@@ -255,11 +265,11 @@ class Request
         end
 
         until socks.empty?
-          _, available_socks, = IO.select(nil, socks, nil, Request::TIMEOUT[:connect])
+          _, available_socks, = IO.select(nil, socks, nil, Request::TIMEOUT[:connect_timeout])
 
           if available_socks.nil?
             socks.each(&:close)
-            raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
+            raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect_timeout]} seconds"
           end
 
           available_socks.each do |sock|

+ 5 - 3
app/lib/tag_manager.rb

@@ -7,18 +7,18 @@ class TagManager
   include RoutingHelper
 
   def web_domain?(domain)
-    domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.web_domain).zero?
+    domain.nil? || domain.delete_suffix('/').casecmp(Rails.configuration.x.web_domain).zero?
   end
 
   def local_domain?(domain)
-    domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero?
+    domain.nil? || domain.delete_suffix('/').casecmp(Rails.configuration.x.local_domain).zero?
   end
 
   def normalize_domain(domain)
     return if domain.nil?
 
     uri = Addressable::URI.new
-    uri.host = domain.gsub(/[\/]/, '')
+    uri.host = domain.delete_suffix('/')
     uri.normalized_host
   end
 
@@ -27,5 +27,7 @@ class TagManager
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
 
     TagManager.instance.web_domain?(domain)
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
+    false
   end
 end

+ 3 - 0
app/lib/video_metadata_extractor.rb

@@ -43,6 +43,9 @@ class VideoMetadataExtractor
         @height      = video_stream[:height]
         @frame_rate  = video_stream[:avg_frame_rate] == '0/0' ? nil : Rational(video_stream[:avg_frame_rate])
         @r_frame_rate = video_stream[:r_frame_rate] == '0/0' ? nil : Rational(video_stream[:r_frame_rate])
+        # For some video streams the frame_rate reported by `ffprobe` will be 0/0, but for these streams we
+        # should use `r_frame_rate` instead. Video screencast generated by Gnome Screencast have this issue.
+        @frame_rate ||= @r_frame_rate
       end
 
       if (audio_stream = audio_streams.first)

+ 5 - 5
app/models/account.rb

@@ -61,9 +61,9 @@ class Account < ApplicationRecord
     trust_level
   )
 
-  USERNAME_RE   = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
-  MENTION_RE    = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[[:word:]]+)?)/i
-  URL_PREFIX_RE = /\Ahttp(s?):\/\/[^\/]+/
+  USERNAME_RE   = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i
+  MENTION_RE    = %r{(?<![=/[:word:]])@((#{USERNAME_RE})(?:@[[:word:].-]+[[:word:]]+)?)}i
+  URL_PREFIX_RE = %r{\Ahttp(s?)://[^/]+}
 
   include Attachmentable
   include AccountAssociations
@@ -114,8 +114,8 @@ class Account < ApplicationRecord
   scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
   scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
   scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
-  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
-  scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
+  scope :by_recent_status, -> { includes(:account_stat).merge(AccountStat.order('last_status_at DESC NULLS LAST')).references(:account_stat) }
+  scope :by_recent_sign_in, -> { order(Arel.sql('users.current_sign_in_at DESC NULLS LAST')) }
   scope :popular, -> { order('account_stats.followers_count desc') }
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }

+ 1 - 1
app/models/admin/action_log_filter.rb

@@ -31,7 +31,7 @@ class Admin::ActionLogFilter
     destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze,
     destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze,
     destroy_status: { target_type: 'Status', action: 'destroy' }.freeze,
-    disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze,
+    disable_2fa_user: { target_type: 'User', action: 'disable_2fa' }.freeze,
     disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze,
     disable_user: { target_type: 'User', action: 'disable' }.freeze,
     enable_custom_emoji: { target_type: 'CustomEmoji', action: 'enable' }.freeze,

+ 1 - 1
app/models/admin/status_batch_action.rb

@@ -137,6 +137,6 @@ class Admin::StatusBatchAction
   end
 
   def allowed_status_ids
-    AccountStatusesFilter.new(@report.target_account, current_account).results.with_discarded.where(id: status_ids).pluck(:id)
+    Admin::AccountStatusesFilter.new(@report.target_account, current_account).results.with_discarded.where(id: status_ids).pluck(:id)
   end
 end

+ 6 - 2
app/models/concerns/attachmentable.rb

@@ -52,9 +52,13 @@ module Attachmentable
     return if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
 
     width, height = FastImage.size(attachment.queued_for_write[:original].path)
-    matrix_limit  = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT
+    return unless width.present? && height.present?
 
-    raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit)
+    if attachment.content_type == 'image/gif' && width * height > GIF_MATRIX_LIMIT
+      raise Mastodon::DimensionsValidationError, "#{width}x#{height} GIF files are not supported"
+    elsif width * height > MAX_MATRIX_LIMIT
+      raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported"
+    end
   end
 
   def appropriate_extension(attachment)

+ 1 - 1
app/models/media_attachment.rb

@@ -156,7 +156,7 @@ class MediaAttachment < ApplicationRecord
   }.freeze
 
   GLOBAL_CONVERT_OPTIONS = {
-    all: '-quality 90 -strip +set modify-date +set create-date',
+    all: '-quality 90 -strip +set date:modify +set date:create +set date:timestamp',
   }.freeze
 
   belongs_to :account,          inverse_of: :media_attachments, optional: true

+ 5 - 5
app/models/relationship_filter.rb

@@ -60,13 +60,13 @@ class RelationshipFilter
   def relationship_scope(value)
     case value
     when 'following'
-      account.following.eager_load(:account_stat).reorder(nil)
+      account.following.includes(:account_stat).reorder(nil)
     when 'followed_by'
-      account.followers.eager_load(:account_stat).reorder(nil)
+      account.followers.includes(:account_stat).reorder(nil)
     when 'mutual'
-      account.followers.eager_load(:account_stat).reorder(nil).merge(Account.where(id: account.following))
+      account.followers.includes(:account_stat).reorder(nil).merge(Account.where(id: account.following))
     when 'invited'
-      Account.joins(user: :invite).merge(Invite.where(user: account.user)).eager_load(:account_stat).reorder(nil)
+      Account.joins(user: :invite).merge(Invite.where(user: account.user)).includes(:account_stat).reorder(nil)
     else
       raise "Unknown relationship: #{value}"
     end
@@ -112,7 +112,7 @@ class RelationshipFilter
   def activity_scope(value)
     case value
     when 'dormant'
-      AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
+      Account.joins(:account_stat).where(account_stat: { last_status_at: [nil, ...1.month.ago] })
     else
       raise "Unknown activity: #{value}"
     end

+ 4 - 5
app/models/report.rb

@@ -38,7 +38,10 @@ class Report < ApplicationRecord
   scope :resolved,   -> { where.not(action_taken_at: nil) }
   scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
 
-  validates :comment, length: { maximum: 1_000 }
+  # A report is considered local if the reporter is local
+  delegate :local?, to: :account
+
+  validates :comment, length: { maximum: 1_000 }, if: :local?
   validates :rule_ids, absence: true, unless: :violation?
 
   validate :validate_rule_ids
@@ -49,10 +52,6 @@ class Report < ApplicationRecord
     violation: 2_000,
   }
 
-  def local?
-    false # Force uri_for to use uri attribute
-  end
-
   before_validation :set_uri, only: :create
 
   def object_type

+ 12 - 0
app/models/status.rb

@@ -346,13 +346,25 @@ class Status < ApplicationRecord
 
       account_ids.uniq!
 
+      status_ids = cached_items.map { |item| item.reblog? ? item.reblog_of_id : item.id }.uniq
+
       return if account_ids.empty?
 
       accounts = Account.where(id: account_ids).includes(:account_stat, :user).index_by(&:id)
 
+      status_stats = StatusStat.where(status_id: status_ids).index_by(&:status_id)
+
       cached_items.each do |item|
         item.account = accounts[item.account_id]
         item.reblog.account = accounts[item.reblog.account_id] if item.reblog?
+
+        if item.reblog?
+          status_stat = status_stats[item.reblog.id]
+          item.reblog.status_stat = status_stat if status_stat.present?
+        else
+          status_stat = status_stats[item.id]
+          item.status_stat = status_stat if status_stat.present?
+        end
       end
     end
 

+ 1 - 1
app/models/trends/statuses.rb

@@ -75,7 +75,7 @@ class Trends::Statuses < Trends::Base
   private
 
   def eligible?(status)
-    status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply?
+    status.public_visibility? && status.account.discoverable? && !status.account.silenced? && !status.account.sensitized? && status.spoiler_text.blank? && !status.sensitive? && !status.reply?
   end
 
   def calculate_scores(statuses, at_time)

+ 1 - 1
app/policies/backup_policy.rb

@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class BackupPolicy < ApplicationPolicy
-  MIN_AGE = 1.week
+  MIN_AGE = 6.days
 
   def create?
     user_signed_in? && current_user.backups.where('created_at >= ?', MIN_AGE.ago).count.zero?

+ 3 - 3
app/services/activitypub/fetch_remote_account_service.rb

@@ -8,15 +8,15 @@ class ActivityPub::FetchRemoteAccountService < BaseService
   SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
 
   # Does a WebFinger roundtrip on each call, unless `only_key` is true
-  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, request_id: nil)
+  def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, request_id: nil)
     return if domain_not_allowed?(uri)
     return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
 
     @json = begin
       if prefetched_body.nil?
-        fetch_resource(uri, id)
+        fetch_resource(uri, true)
       else
-        body_to_json(prefetched_body, compare_id: id ? uri : nil)
+        body_to_json(prefetched_body, compare_id: uri)
       end
     end
 

+ 2 - 15
app/services/activitypub/fetch_remote_key_service.rb

@@ -4,23 +4,10 @@ class ActivityPub::FetchRemoteKeyService < BaseService
   include JsonLdHelper
 
   # Returns account that owns the key
-  def call(uri, id: true, prefetched_body: nil)
+  def call(uri)
     return if uri.blank?
 
-    if prefetched_body.nil?
-      if id
-        @json = fetch_resource_without_id_validation(uri)
-        if person?
-          @json = fetch_resource(@json['id'], true)
-        elsif uri != @json['id']
-          return
-        end
-      else
-        @json = fetch_resource(uri, id)
-      end
-    else
-      @json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
-    end
+    @json = fetch_resource(uri, false)
 
     return unless supported_context?(@json) && expected_type?
     return find_account(@json['id'], @json) if person?

+ 4 - 4
app/services/activitypub/fetch_remote_status_service.rb

@@ -7,13 +7,13 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   DISCOVERIES_PER_REQUEST = 1000
 
   # Should be called when uri has already been checked for locality
-  def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
+  def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
     @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
     @json = begin
       if prefetched_body.nil?
-        fetch_resource(uri, id, on_behalf_of)
+        fetch_resource(uri, true, on_behalf_of)
       else
-        body_to_json(prefetched_body, compare_id: id ? uri : nil)
+        body_to_json(prefetched_body, compare_id: uri)
       end
     end
 
@@ -63,7 +63,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
 
   def account_from_uri(uri)
     actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
-    actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale?
+    actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale?
     actor
   end
 

+ 4 - 1
app/services/activitypub/process_account_service.rb

@@ -77,6 +77,9 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.suspended_at      = domain_block.created_at if auto_suspend?
     @account.suspension_origin = :local if auto_suspend?
     @account.silenced_at       = domain_block.created_at if auto_silence?
+
+    set_immediate_protocol_attributes!
+
     @account.save
   end
 
@@ -266,7 +269,7 @@ class ActivityPub::ProcessAccountService < BaseService
 
   def moved_account
     account   = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
-    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id])
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id])
     account
   end
 

+ 9 - 1
app/services/fetch_resource_service.rb

@@ -47,7 +47,15 @@ class FetchResourceService < BaseService
       body = response.body_with_limit
       json = body_to_json(body)
 
-      [json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) || expected_type?(json))
+      return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) || expected_type?(json))
+
+      if json['id'] != @url
+        return if terminal
+
+        return process(json['id'], terminal: true)
+      end
+
+      [@url, { prefetched_body: body }]
     elsif !terminal
       link_header = response['Link'] && parse_link_header(response)
 

+ 1 - 1
app/services/follow_service.rb

@@ -70,7 +70,7 @@ class FollowService < BaseService
     if @target_account.local?
       LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
     elsif @target_account.activitypub?
-      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, { 'bypass_availability' => true })
     end
 
     follow_request

+ 1 - 1
app/views/admin/trends/links/preview_card_providers/index.html.haml

@@ -29,7 +29,7 @@
   - Trends::PreviewCardProviderFilter::KEYS.each do |key|
     = hidden_field_tag key, params[key] if params[key].present?
 
-  .batch-table.optional
+  .batch-table
     .batch-table__toolbar
       %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false

+ 3 - 2
app/workers/activitypub/delivery_worker.rb

@@ -13,9 +13,10 @@ class ActivityPub::DeliveryWorker
   HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
 
   def perform(json, source_account_id, inbox_url, options = {})
-    return unless DeliveryFailureTracker.available?(inbox_url)
-
     @options        = options.with_indifferent_access
+
+    return unless @options[:bypass_availability] || DeliveryFailureTracker.available?(inbox_url)
+
     @json           = json
     @source_account = Account.find(source_account_id)
     @inbox_url      = inbox_url

+ 5 - 1
config/initializers/content_security_policy.rb

@@ -3,7 +3,11 @@
 # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
 
 def host_to_url(str)
-  "http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}".split('/').first if str.present?
+  return if str.blank?
+
+  uri = Addressable::URI.parse("http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}")
+  uri.path += '/' unless uri.path.blank? || uri.path.end_with?('/')
+  uri.to_s
 end
 
 base_host = Rails.configuration.x.web_domain

+ 5 - 0
config/initializers/sidekiq.rb

@@ -3,6 +3,11 @@
 require_relative '../../lib/mastodon/sidekiq_middleware'
 
 Sidekiq.configure_server do |config|
+  if Rails.configuration.database_configuration.dig('production', 'adapter') == 'postgresql_makara'
+    STDERR.puts 'ERROR: Database replication is not currently supported in Sidekiq workers. Check your configuration.'
+    exit 1
+  end
+
   config.redis = REDIS_SIDEKIQ_PARAMS
 
   config.server_middleware do |chain|

+ 1 - 1
config/routes.rb

@@ -226,7 +226,7 @@ Rails.application.routes.draw do
       end
     end
 
-    resources :instances, only: [:index, :show, :destroy], constraints: { id: /[^\/]+/ } do
+    resources :instances, only: [:index, :show, :destroy], constraints: { id: /[^\/]+/ }, format: 'html' do
       member do
         post :clear_delivery_errors
         post :restart_delivery

+ 3 - 3
docker-compose.yml

@@ -44,7 +44,7 @@ services:
 
   web:
     build: .
-    image: ghcr.io/mastodon/mastodon:v3.5.9
+    image: ghcr.io/mastodon/mastodon:v3.5.17
     restart: always
     env_file: .env.production
     command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
@@ -65,7 +65,7 @@ services:
 
   streaming:
     build: .
-    image: ghcr.io/mastodon/mastodon:v3.5.9
+    image: ghcr.io/mastodon/mastodon:v3.5.17
     restart: always
     env_file: .env.production
     command: node ./streaming
@@ -83,7 +83,7 @@ services:
 
   sidekiq:
     build: .
-    image: ghcr.io/mastodon/mastodon:v3.5.9
+    image: ghcr.io/mastodon/mastodon:v3.5.17
     restart: always
     env_file: .env.production
     command: bundle exec sidekiq

+ 1 - 1
lib/mastodon/version.rb

@@ -13,7 +13,7 @@ module Mastodon
     end
 
     def patch
-      10
+      17
     end
 
     def flags

+ 8 - 6
lib/paperclip/transcoder.rb

@@ -37,12 +37,14 @@ module Paperclip
         @output_options['f']       = 'image2'
         @output_options['vframes'] = 1
       when 'mp4'
-        @output_options['acodec'] = 'aac'
-        @output_options['strict'] = 'experimental'
-
-        if high_vfr?(metadata) && !eligible_to_passthrough?(metadata)
-          @output_options['vsync'] = 'vfr'
-          @output_options['r'] = @vfr_threshold
+        unless eligible_to_passthrough?(metadata)
+          @output_options['acodec'] = 'aac'
+          @output_options['strict'] = 'experimental'
+
+          if high_vfr?(metadata)
+            @output_options['vsync'] = 'vfr'
+            @output_options['r'] = @vfr_threshold
+          end
         end
       end
 

+ 16 - 4
spec/controllers/admin/statuses_controller_spec.rb

@@ -40,24 +40,36 @@ describe Admin::StatusesController do
   end
 
   describe 'POST #batch' do
-    before do
-      post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } }
-    end
+    subject { post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } } }
 
     let(:status_ids) { [media_attached_status.id] }
 
-    context 'when action is report' do
+    shared_examples 'when action is report' do
       let(:action) { 'report' }
 
       it 'creates a report' do
+        subject
+
         report = Report.last
         expect(report.target_account_id).to eq account.id
         expect(report.status_ids).to eq status_ids
       end
 
       it 'redirects to report page' do
+        subject
+
         expect(response).to redirect_to(admin_report_path(Report.last.id))
       end
     end
+
+    it_behaves_like 'when action is report'
+
+    context 'when the moderator is blocked by the author' do
+      before do
+        account.block!(user.account)
+      end
+
+      it_behaves_like 'when action is report'
+    end
   end
 end

+ 53 - 18
spec/controllers/api/v1/timelines/tag_controller_spec.rb

@@ -5,36 +5,71 @@ require 'rails_helper'
 describe Api::V1::Timelines::TagController do
   render_views
 
-  let(:user) { Fabricate(:user) }
+  let(:user)   { Fabricate(:user) }
+  let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') }
 
   before do
     allow(controller).to receive(:doorkeeper_token) { token }
   end
 
-  context 'with a user context' do
-    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) }
+  describe 'GET #show' do
+    subject do
+      get :show, params: { id: 'test' }
+    end
+
+    before do
+      PostStatusService.new.call(user.account, text: 'It is a #test')
+    end
+
+    context 'when the instance allows public preview' do
+      context 'when the user is not authenticated' do
+        let(:token) { nil }
 
-    describe 'GET #show' do
-      before do
-        PostStatusService.new.call(user.account, text: 'It is a #test')
+        it 'returns http success', :aggregate_failures do
+          subject
+
+          expect(response).to have_http_status(200)
+          expect(response.headers['Link'].links.size).to eq(2)
+        end
       end
 
-      it 'returns http success' do
-        get :show, params: { id: 'test' }
-        expect(response).to have_http_status(200)
-        expect(response.headers['Link'].links.size).to eq(2)
+      context 'when the user is authenticated' do
+        it 'returns http success', :aggregate_failures do
+          subject
+
+          expect(response).to have_http_status(200)
+          expect(response.headers['Link'].links.size).to eq(2)
+        end
       end
     end
-  end
 
-  context 'without a user context' do
-    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil) }
+    context 'when the instance does not allow public preview' do
+      around do |example|
+        timeline_preview = Setting.timeline_preview
+        Setting.timeline_preview = false
+
+        example.run
+
+        Setting.timeline_preview = timeline_preview
+      end
+
+      context 'when the user is not authenticated' do
+        let(:token) { nil }
+
+        it 'returns http unauthorized' do
+          subject
+
+          expect(response).to have_http_status(401)
+        end
+      end
+
+      context 'when the user is authenticated' do
+        it 'returns http success', :aggregate_failures do
+          subject
 
-    describe 'GET #show' do
-      it 'returns http success' do
-        get :show, params: { id: 'test' }
-        expect(response).to have_http_status(200)
-        expect(response.headers['Link']).to be_nil
+          expect(response).to have_http_status(200)
+          expect(response.headers['Link'].links.size).to eq(2)
+        end
       end
     end
   end

+ 22 - 2
spec/controllers/concerns/cache_concern_spec.rb

@@ -13,12 +13,17 @@ RSpec.describe CacheConcern, type: :controller do
     def empty_relation
       render plain: cache_collection(Status.none, Status).size
     end
+
+    def account_statuses_favourites
+      render plain: cache_collection(Status.where(account_id: params[:id]), Status).map(&:favourites_count)
+    end
   end
 
   before do
     routes.draw do
-      get  'empty_array' => 'anonymous#empty_array'
-      post 'empty_relation' => 'anonymous#empty_relation'
+      get 'empty_array' => 'anonymous#empty_array'
+      get 'empty_relation' => 'anonymous#empty_relation'
+      get 'account_statuses_favourites' => 'anonymous#account_statuses_favourites'
     end
   end
 
@@ -36,5 +41,20 @@ RSpec.describe CacheConcern, type: :controller do
         expect(response.body).to eq '0'
       end
     end
+
+    context 'when given a collection of statuses' do
+      let!(:account) { Fabricate(:account) }
+      let!(:status)  { Fabricate(:status, account: account) }
+
+      it 'correctly updates with new interactions' do
+        get :account_statuses_favourites, params: { id: account.id }
+        expect(response.body).to eq '[0]'
+
+        FavouriteService.new.call(account, status)
+
+        get :account_statuses_favourites, params: { id: account.id }
+        expect(response.body).to eq '[1]'
+      end
+    end
   end
 end

+ 6 - 4
spec/fabricators/account_stat_fabricator.rb

@@ -1,6 +1,8 @@
+# frozen_string_literal: true
+
 Fabricator(:account_stat) do
-  account         nil
-  statuses_count  ""
-  following_count ""
-  followers_count ""
+  account { Fabricate.build(:account) }
+  statuses_count  '123'
+  following_count '456'
+  followers_count '789'
 end

+ 44 - 6
spec/lib/activitypub/activity/create_spec.rb

@@ -29,29 +29,67 @@ RSpec.describe ActivityPub::Activity::Create do
         subject.perform
       end
 
-      context 'object has been edited' do
+      context 'when object publication date is below ISO8601 range' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
             type: 'Note',
             content: 'Lorem ipsum',
-            published: '2022-01-22T15:00:00Z',
-            updated: '2022-01-22T16:00:00Z',
+            published: '-0977-11-03T08:31:22Z',
           }
         end
 
-        it 'creates status' do
+        it 'creates status with a valid creation date', :aggregate_failures do
+          status = sender.statuses.first
+
+          expect(status).to_not be_nil
+          expect(status.text).to eq 'Lorem ipsum'
+
+          expect(status.created_at).to be_within(30).of(Time.now.utc)
+        end
+      end
+
+      context 'when object publication date is above ISO8601 range' do
+        let(:object_json) do
+          {
+            id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
+            type: 'Note',
+            content: 'Lorem ipsum',
+            published: '10000-11-03T08:31:22Z',
+          }
+        end
+
+        it 'creates status with a valid creation date', :aggregate_failures do
           status = sender.statuses.first
 
           expect(status).to_not be_nil
           expect(status.text).to eq 'Lorem ipsum'
+
+          expect(status.created_at).to be_within(30).of(Time.now.utc)
         end
+      end
 
-        it 'marks status as edited' do
+      context 'when object has been edited' do
+        let(:object_json) do
+          {
+            id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
+            type: 'Note',
+            content: 'Lorem ipsum',
+            published: '2022-01-22T15:00:00Z',
+            updated: '2022-01-22T16:00:00Z',
+          }
+        end
+
+        it 'creates status with appropriate creation and edition dates', :aggregate_failures do
           status = sender.statuses.first
 
           expect(status).to_not be_nil
-          expect(status.edited?).to eq true
+          expect(status.text).to eq 'Lorem ipsum'
+
+          expect(status.created_at).to eq '2022-01-22T15:00:00Z'.to_datetime
+
+          expect(status.edited?).to be true
+          expect(status.edited_at).to eq '2022-01-22T16:00:00Z'.to_datetime
         end
       end
 

+ 31 - 0
spec/lib/activitypub/activity/flag_spec.rb

@@ -37,6 +37,37 @@ RSpec.describe ActivityPub::Activity::Flag do
       end
     end
 
+    context 'when the report comment is excessively long' do
+      subject do
+        described_class.new({
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: flag_id,
+          type: 'Flag',
+          content: long_comment,
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: [
+            ActivityPub::TagManager.instance.uri_for(flagged),
+            ActivityPub::TagManager.instance.uri_for(status),
+          ],
+        }.with_indifferent_access, sender)
+      end
+
+      let(:long_comment) { Faker::Lorem.characters(number: 6000) }
+
+      before do
+        subject.perform
+      end
+
+      it 'creates a report but with a truncated comment' do
+        report = Report.find_by(account: sender, target_account: flagged)
+
+        expect(report).to_not be_nil
+        expect(report.comment.length).to eq 5000
+        expect(report.comment).to eq long_comment[0...5000]
+        expect(report.status_ids).to eq [status.id]
+      end
+    end
+
     context 'when the reported status is private and should not be visible to the remote server' do
       let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) }
 

+ 34 - 0
spec/lib/activitypub/linked_data_signature_spec.rb

@@ -36,6 +36,40 @@ RSpec.describe ActivityPub::LinkedDataSignature do
       end
     end
 
+    context 'when local account record is missing a public key' do
+      let(:raw_signature) do
+        {
+          'creator' => 'http://example.com/alice',
+          'created' => '2017-09-23T20:21:34Z',
+        }
+      end
+
+      let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) }
+
+      let(:service_stub) { instance_double(ActivityPub::FetchRemoteKeyService) }
+
+      before do
+        # Ensure signature is computed with the old key
+        signature
+
+        # Unset key
+        old_key = sender.public_key
+        sender.update!(private_key: '', public_key: '')
+
+        allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
+
+        allow(service_stub).to receive(:call).with('http://example.com/alice') do
+          sender.update!(public_key: old_key)
+          sender
+        end
+      end
+
+      it 'fetches key and returns creator' do
+        expect(subject.verify_account!).to eq sender
+        expect(service_stub).to have_received(:call).with('http://example.com/alice').once
+      end
+    end
+
     context 'when signature is missing' do
       let(:signature) { nil }
 

+ 1 - 1
spec/models/account_spec.rb

@@ -689,7 +689,7 @@ RSpec.describe Account, type: :model do
       expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil
     end
 
-    xit 'does not match URL querystring' do
+    it 'does not match URL query string' do
       expect(subject.match('https://example.com/?x=@alice')).to be_nil
     end
   end

+ 48 - 20
spec/models/relationship_filter_spec.rb

@@ -6,32 +6,60 @@ describe RelationshipFilter do
   let(:account) { Fabricate(:account) }
 
   describe '#results' do
-    context 'when default params are used' do
-      let(:subject) do
-        RelationshipFilter.new(account, 'order' => 'active').results
-      end
+    let(:account_of_7_months) { Fabricate(:account_stat, statuses_count: 1, last_status_at: 7.months.ago).account }
+    let(:account_of_1_day)    { Fabricate(:account_stat, statuses_count: 1, last_status_at: 1.day.ago).account }
+    let(:account_of_3_days)   { Fabricate(:account_stat, statuses_count: 1, last_status_at: 3.days.ago).account }
+    let(:silent_account)      { Fabricate(:account_stat, statuses_count: 0, last_status_at: nil).account }
+
+    before do
+      account.follow!(account_of_7_months)
+      account.follow!(account_of_1_day)
+      account.follow!(account_of_3_days)
+      account.follow!(silent_account)
+    end
 
-      before do
-        add_following_account_with(last_status_at: 7.days.ago)
-        add_following_account_with(last_status_at: 1.day.ago)
-        add_following_account_with(last_status_at: 3.days.ago)
+    context 'when ordering by last activity' do
+      context 'when not filtering' do
+        subject do
+          described_class.new(account, 'order' => 'active').results
+        end
+
+        it 'returns followings ordered by last activity' do
+          expect(subject).to eq [account_of_1_day, account_of_3_days, account_of_7_months, silent_account]
+        end
       end
 
-      it 'returns followings ordered by last activity' do
-        expected_result = account.following.eager_load(:account_stat).reorder(nil).by_recent_status
+      context 'when filtering for dormant accounts' do
+        subject do
+          described_class.new(account, 'order' => 'active', 'activity' => 'dormant').results
+        end
 
-        expect(subject).to eq expected_result
+        it 'returns dormant followings ordered by last activity' do
+          expect(subject).to eq [account_of_7_months, silent_account]
+        end
       end
     end
-  end
 
-  def add_following_account_with(last_status_at:)
-    following_account = Fabricate(:account)
-    Fabricate(:account_stat, account: following_account,
-                             last_status_at: last_status_at,
-                             statuses_count: 1,
-                             following_count: 0,
-                             followers_count: 0)
-    Fabricate(:follow, account: account, target_account: following_account).account
+    context 'when ordering by account creation' do
+      context 'when not filtering' do
+        subject do
+          described_class.new(account, 'order' => 'recent').results
+        end
+
+        it 'returns followings ordered by last account creation' do
+          expect(subject).to eq [silent_account, account_of_3_days, account_of_1_day, account_of_7_months]
+        end
+      end
+
+      context 'when filtering for dormant accounts' do
+        subject do
+          described_class.new(account, 'order' => 'recent', 'activity' => 'dormant').results
+        end
+
+        it 'returns dormant followings ordered by last activity' do
+          expect(subject).to eq [silent_account, account_of_7_months]
+        end
+      end
+    end
   end
 end

+ 9 - 2
spec/models/report_spec.rb

@@ -125,10 +125,17 @@ describe Report do
       expect(report).to be_valid
     end
 
-    it 'is invalid if comment is longer than 1000 characters' do
+    let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }
+
+    it 'is invalid if comment is longer than 1000 characters only if reporter is local' do
       report = Fabricate.build(:report, comment: Faker::Lorem.characters(number: 1001))
-      report.valid?
+      expect(report.valid?).to be false
       expect(report).to model_have_error_on_field(:comment)
     end
+
+    it 'is valid if comment is longer than 1000 characters and reporter is not local' do
+      report = Fabricate.build(:report, account: remote_account, comment: Faker::Lorem.characters(number: 1001))
+      expect(report.valid?).to be true
+    end
   end
 end

+ 1 - 1
spec/requests/api/v2/media_spec.rb

@@ -12,7 +12,7 @@ RSpec.describe 'Media API', paperclip_processing: true do
     it 'returns http success' do
       post '/api/v2/media', headers: headers, params: { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') }
       expect(File.exist?(user.account.media_attachments.first.file.path(:small))).to be true
-      expect(response).to have_http_status(200)
+      expect(response).to have_http_status(202)
     end
   end
 end

+ 27 - 0
spec/requests/content_security_policy_spec.rb

@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Content-Security-Policy' do
+  it 'sets the expected CSP headers' do
+    allow(SecureRandom).to receive(:base64).with(16).and_return('ZbA+JmE7+bK8F5qvADZHuQ==')
+
+    get '/'
+    expect(response.headers['Content-Security-Policy'].split(';').map(&:strip)).to contain_exactly(
+      "base-uri 'none'",
+      "default-src 'none'",
+      "frame-ancestors 'none'",
+      "font-src 'self' https://cb6e6126.ngrok.io",
+      "img-src 'self' https: data: blob: https://cb6e6126.ngrok.io",
+      "style-src 'self' https://cb6e6126.ngrok.io 'nonce-ZbA+JmE7+bK8F5qvADZHuQ=='",
+      "media-src 'self' https: data: https://cb6e6126.ngrok.io",
+      "frame-src 'self' https:",
+      "manifest-src 'self' https://cb6e6126.ngrok.io",
+      "form-action 'self'",
+      "child-src 'self' blob: https://cb6e6126.ngrok.io",
+      "worker-src 'self' blob: https://cb6e6126.ngrok.io",
+      "connect-src 'self' data: blob: https://cb6e6126.ngrok.io https://cb6e6126.ngrok.io ws://localhost:4000",
+      "script-src 'self' https://cb6e6126.ngrok.io"
+    )
+  end
+end

+ 1 - 1
spec/services/activitypub/fetch_remote_account_service_spec.rb

@@ -16,7 +16,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
   end
 
   describe '#call' do
-    let(:account) { subject.call('https://example.com/alice', id: true) }
+    let(:account) { subject.call('https://example.com/alice') }
 
     shared_examples 'sets profile data' do
       it 'returns an account' do

+ 5 - 5
spec/services/fetch_resource_service_spec.rb

@@ -54,7 +54,7 @@ RSpec.describe FetchResourceService, type: :service do
 
       let(:json) do
         {
-          id: 1,
+          id: 'http://example.com/foo',
           '@context': ActivityPub::TagManager::CONTEXT,
           type: 'Note',
         }.to_json
@@ -79,14 +79,14 @@ RSpec.describe FetchResourceService, type: :service do
         let(:content_type) { 'application/activity+json; charset=utf-8' }
         let(:body) { json }
 
-        it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
       end
 
       context 'when content type is ld+json with profile' do
         let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
         let(:body) { json }
 
-        it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
       end
 
       before do
@@ -97,14 +97,14 @@ RSpec.describe FetchResourceService, type: :service do
       context 'when link header is present' do
         let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"', } }
 
-        it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
       end
 
       context 'when content type is text/html' do
         let(:content_type) { 'text/html' }
         let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' }
 
-        it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
       end
     end
   end

+ 8 - 0
spec/services/report_service_spec.rb

@@ -4,6 +4,14 @@ RSpec.describe ReportService, type: :service do
   subject { described_class.new }
 
   let(:source_account) { Fabricate(:account) }
+  let(:target_account) { Fabricate(:account) }
+
+  context 'with a local account' do
+    it 'has a uri' do
+      report = subject.call(source_account, target_account)
+      expect(report.uri).to_not be_nil
+    end
+  end
 
   context 'for a remote account' do
     let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }

+ 1 - 0
spec/services/resolve_url_service_spec.rb

@@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do
         stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url })
         body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json
         stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
+        stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
       end
 
       it 'returns status by url' do

+ 16 - 9
streaming/index.js

@@ -228,8 +228,14 @@ const startWorker = async (workerId) => {
   };
 
   /**
+   * @callback SubscriptionListener
+   * @param {ReturnType<parseJSON>} json of the message
+   * @returns void
+   */
+
+  /**
    * @param {string} channel
-   * @param {function(string): void} callback
+   * @param {SubscriptionListener} callback
    */
   const subscribe = (channel, callback) => {
     log.silly(`Adding listener for ${channel}`);
@@ -246,7 +252,7 @@ const startWorker = async (workerId) => {
 
   /**
    * @param {string} channel
-   * @param {function(Object<string, any>): void} callback
+   * @param {SubscriptionListener} callback
    */
   const unsubscribe = (channel, callback) => {
     log.silly(`Removing listener for ${channel}`);
@@ -625,9 +631,9 @@ const startWorker = async (workerId) => {
    * @param {string[]} ids
    * @param {any} req
    * @param {function(string, string): void} output
-   * @param {function(string[], function(string): void): void} attachCloseHandler
+   * @param {undefined | function(string[], SubscriptionListener): void} attachCloseHandler
    * @param {boolean=} needsFiltering
-   * @returns {function(object): void}
+   * @returns {SubscriptionListener}
    */
   const streamFrom = (ids, req, output, attachCloseHandler, needsFiltering = false) => {
     const accountId = req.accountId || req.remoteAddress;
@@ -710,7 +716,7 @@ const startWorker = async (workerId) => {
       subscribe(`${redisPrefix}${id}`, listener);
     });
 
-    if (attachCloseHandler) {
+    if (typeof attachCloseHandler === 'function') {
       attachCloseHandler(ids.map(id => `${redisPrefix}${id}`), listener);
     }
 
@@ -747,12 +753,13 @@ const startWorker = async (workerId) => {
   /**
    * @param {any} req
    * @param {function(): void} [closeHandler]
-   * @return {function(string[]): void}
+   * @returns {function(string[], SubscriptionListener): void}
    */
-  const streamHttpEnd = (req, closeHandler = undefined) => (ids) => {
+
+  const streamHttpEnd = (req, closeHandler = undefined) => (ids, listener) => {
     req.on('close', () => {
       ids.forEach(id => {
-        unsubscribe(id);
+        unsubscribe(id, listener);
       });
 
       if (closeHandler) {
@@ -994,7 +1001,7 @@ const startWorker = async (workerId) => {
    * @typedef WebSocketSession
    * @property {any} socket
    * @property {any} request
-   * @property {Object.<string, { listener: function(string): void, stopHeartbeat: function(): void }>} subscriptions
+   * @property {Object.<string, { listener: SubscriptionListener, stopHeartbeat: function(): void }>} subscriptions
    */
 
   /**