Merge branch 'main' into glitch-soc/merge-upstream

Conflicts:
- `README.md`:
  Discarded upstream changes: we have our own README
- `app/controllers/follower_accounts_controller.rb`:
  Port upstream's minor refactoring
local
Claire 1 year ago
commit f3a4d57be1
  1. 12
      .circleci/config.yml
  2. 2
      .devcontainer/Dockerfile
  3. 4
      .devcontainer/devcontainer.json
  4. 4
      .devcontainer/docker-compose.yml
  5. 21
      .devcontainer/post-create.sh
  6. 2
      .env.production.sample
  7. 1
      .github/workflows/build-image.yml
  8. 63
      .github/workflows/codeql.yml
  9. 2
      .nvmrc
  10. 200
      .rubocop.yml
  11. 22
      Aptfile
  12. 7
      Dockerfile
  13. 11
      Gemfile
  14. 33
      Gemfile.lock
  15. 8
      app/controllers/admin/domain_blocks_controller.rb
  16. 6
      app/controllers/admin/relays_controller.rb
  17. 20
      app/controllers/api/base_controller.rb
  18. 6
      app/controllers/api/v1/admin/domain_blocks_controller.rb
  19. 2
      app/controllers/api/v1/notifications_controller.rb
  20. 2
      app/controllers/auth/passwords_controller.rb
  21. 4
      app/controllers/auth/registrations_controller.rb
  22. 2
      app/controllers/concerns/rate_limit_headers.rb
  23. 4
      app/controllers/concerns/signature_verification.rb
  24. 2
      app/controllers/follower_accounts_controller.rb
  25. 2
      app/controllers/following_accounts_controller.rb
  26. 4
      app/controllers/media_controller.rb
  27. 4
      app/controllers/statuses_controller.rb
  28. 2
      app/controllers/tags_controller.rb
  29. 25
      app/helpers/formatting_helper.rb
  30. 3
      app/helpers/languages_helper.rb
  31. 2
      app/helpers/statuses_helper.rb
  32. 2
      app/javascript/images/logo-symbol-icon.svg
  33. 2
      app/javascript/images/logo-symbol-wordmark.svg
  34. 4
      app/javascript/mastodon/actions/announcements.js
  35. 11
      app/javascript/mastodon/components/status_action_bar.js
  36. 4
      app/javascript/mastodon/containers/mastodon.js
  37. 37
      app/javascript/mastodon/features/account/components/follow_request_note.js
  38. 3
      app/javascript/mastodon/features/account/components/header.js
  39. 15
      app/javascript/mastodon/features/account/containers/follow_request_note_container.js
  40. 1
      app/javascript/mastodon/features/account_gallery/components/media_item.js
  41. 13
      app/javascript/mastodon/features/compose/components/compose_form.js
  42. 1
      app/javascript/mastodon/features/compose/components/poll_form.js
  43. 29
      app/javascript/mastodon/features/compose/components/search.js
  44. 1
      app/javascript/mastodon/features/compose/containers/compose_form_container.js
  45. 3
      app/javascript/mastodon/features/compose/index.js
  46. 32
      app/javascript/mastodon/features/explore/index.js
  47. 2
      app/javascript/mastodon/features/hashtag_timeline/index.js
  48. 2
      app/javascript/mastodon/features/ui/components/columns_area.js
  49. 6
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  50. 22
      app/javascript/mastodon/locales/defaultMessages.json
  51. 11
      app/javascript/mastodon/locales/en.json
  52. 2
      app/javascript/mastodon/reducers/compose.js
  53. 11
      app/javascript/mastodon/reducers/relationships.js
  54. 38
      app/javascript/packs/public.js
  55. 2
      app/javascript/styles/mastodon/admin.scss
  56. 42
      app/javascript/styles/mastodon/components.scss
  57. 2
      app/javascript/styles/mastodon/modal.scss
  58. 2
      app/javascript/styles/mastodon/widgets.scss
  59. 1
      app/lib/admin/system_check/elasticsearch_check.rb
  60. 3
      app/lib/request.rb
  61. 2
      app/lib/status_reach_finder.rb
  62. 2
      app/lib/translation_service/libre_translate.rb
  63. 12
      app/models/account.rb
  64. 2
      app/models/account_filter.rb
  65. 4
      app/models/concerns/account_interactions.rb
  66. 2
      app/models/media_attachment.rb
  67. 16
      app/models/user.rb
  68. 6
      app/presenters/account_relationships_presenter.rb
  69. 2
      app/serializers/initial_state_serializer.rb
  70. 8
      app/serializers/rest/relationship_serializer.rb
  71. 42
      app/services/activitypub/process_status_update_service.rb
  72. 12
      app/services/post_status_service.rb
  73. 18
      app/services/tag_search_service.rb
  74. 2
      app/views/admin/accounts/index.html.haml
  75. 6
      app/views/admin/accounts/show.html.haml
  76. 3
      app/views/admin/export_domain_blocks/import.html.haml
  77. 7
      app/views/admin/report_notes/_report_note.html.haml
  78. 7
      app/views/admin/reports/show.html.haml
  79. 2
      app/views/auth/sessions/new.html.haml
  80. 7
      app/views/disputes/strikes/show.html.haml
  81. 2
      app/views/settings/featured_tags/index.html.haml
  82. 2
      app/workers/scheduler/suspended_user_cleanup_scheduler.rb
  83. 1
      config/application.rb
  84. 2
      config/initializers/devise.rb
  85. 10
      config/locales/doorkeeper.en.yml
  86. 8
      config/locales/en.yml
  87. 2
      config/sidekiq.yml
  88. 6
      config/webpack/production.js
  89. 41
      lib/mastodon/accounts_cli.rb
  90. 78
      lib/mastodon/media_cli.rb
  91. 2
      lib/tasks/mastodon.rake
  92. 2
      package.json
  93. 22
      public/embed.js
  94. 47
      spec/controllers/admin/domain_blocks_controller_spec.rb
  95. 47
      spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
  96. 61
      spec/controllers/auth/passwords_controller_spec.rb
  97. 2
      spec/controllers/well_known/nodeinfo_controller_spec.rb
  98. 2
      spec/helpers/application_helper_spec.rb
  99. 24
      spec/helpers/formatting_helper_spec.rb
  100. 8
      spec/models/account_spec.rb
  101. Some files were not shown because too many files have changed in this diff Show More

@ -1,8 +1,8 @@
version: 2.1 version: 2.1
orbs: orbs:
ruby: circleci/ruby@1.4.1 ruby: circleci/ruby@2.0.0
node: circleci/node@5.0.1 node: circleci/node@5.0.3
executors: executors:
default: default:
@ -19,11 +19,11 @@ executors:
DB_USER: root DB_USER: root
DISABLE_SIMPLECOV: true DISABLE_SIMPLECOV: true
RAILS_ENV: test RAILS_ENV: test
- image: cimg/postgres:14.0 - image: cimg/postgres:14.5
environment: environment:
POSTGRES_USER: root POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
- image: cimg/redis:6.2 - image: cimg/redis:7.0
commands: commands:
install-system-dependencies: install-system-dependencies:
@ -45,7 +45,7 @@ commands:
bundle config without 'development production' bundle config without 'development production'
name: Set bundler settings name: Set bundler settings
- ruby/install-deps: - ruby/install-deps:
bundler-version: '2.3.8' bundler-version: '2.3.26'
key: ruby<< parameters.ruby-version >>-gems-v1 key: ruby<< parameters.ruby-version >>-gems-v1
wait-db: wait-db:
steps: steps:
@ -221,5 +221,5 @@ workflows:
pkg-manager: yarn pkg-manager: yarn
requires: requires:
- build - build
version: lts version: '16.18'
yarn-run: test:jest yarn-run: test:jest

@ -9,7 +9,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
# The value is a comma-separated list of allowed domains # The value is a comma-separated list of allowed domains
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev" ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev"
# [Choice] Node.js version: lts/*, 16, 14, 12, 10 # [Choice] Node.js version: lts/*, 18, 16, 14
ARG NODE_VERSION="lts/*" ARG NODE_VERSION="lts/*"
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1" RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"

@ -2,7 +2,7 @@
"name": "Mastodon", "name": "Mastodon",
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"service": "app", "service": "app",
"workspaceFolder": "/workspaces/mastodon", "workspaceFolder": "/mastodon",
// Set *default* container specific settings.json values on container create. // Set *default* container specific settings.json values on container create.
"settings": {}, "settings": {},
@ -20,7 +20,7 @@
"forwardPorts": [3000, 4000], "forwardPorts": [3000, 4000],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bundle install --path vendor/bundle && yarn install && git checkout -- Gemfile.lock && ./bin/rails db:setup", "postCreateCommand": ".devcontainer/post-create.sh",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode" "remoteUser": "vscode"

@ -11,9 +11,9 @@ services:
# Use -bullseye variants on local arm64/Apple Silicon. # Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: '3.0-bullseye' VARIANT: '3.0-bullseye'
# Optional Node.js version to install # Optional Node.js version to install
NODE_VERSION: '14' NODE_VERSION: '16'
volumes: volumes:
- ..:/workspaces/mastodon:cached - ..:/mastodon:cached
environment: environment:
RAILS_ENV: development RAILS_ENV: development
NODE_ENV: development NODE_ENV: development

@ -0,0 +1,21 @@
#!/bin/bash
set -e # Fail the whole script on first error
# Fetch Ruby gem dependencies
bundle install --path vendor/bundle --with='development test'
# Fetch Javascript dependencies
yarn install
# Make Gemfile.lock pristine again
git checkout -- Gemfile.lock
# [re]create, migrate, and seed the test database
RAILS_ENV=test ./bin/rails db:setup
# Precompile assets for development
RAILS_ENV=development ./bin/rails assets:precompile
# Precompile assets for test
RAILS_ENV=test NODE_ENV=tests ./bin/rails assets:precompile

@ -103,7 +103,7 @@ VAPID_PUBLIC_KEY=
# Sending mail # Sending mail
# ------------ # ------------
SMTP_SERVER=smtp.mailgun.org SMTP_SERVER=
SMTP_PORT=587 SMTP_PORT=587
SMTP_LOGIN= SMTP_LOGIN=
SMTP_PASSWORD= SMTP_PASSWORD=

@ -17,6 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: hadolint/hadolint-action@v3.0.0
- uses: docker/setup-qemu-action@v2 - uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2 - uses: docker/login-action@v2

@ -0,0 +1,63 @@
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '22 6 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'ruby' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ℹ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

@ -1 +1 @@
14 16

@ -1,12 +1,18 @@
require: require:
- rubocop-rails - rubocop-rails
- rubocop-rspec
- rubocop-performance
AllCops: AllCops:
TargetRubyVersion: 2.7 TargetRubyVersion: 2.7
NewCops: disable DisplayCopNames: true
DisplayStyleGuide: true
ExtraDetails: true
UseCache: true
CacheRootDirectory: tmp
NewCops: enable
Exclude: Exclude:
- 'spec/**/*' - db/schema.rb
- 'db/**/*'
- 'app/views/**/*' - 'app/views/**/*'
- 'config/**/*' - 'config/**/*'
- 'bin/*' - 'bin/*'
@ -67,15 +73,57 @@ Lint/UselessAccessModifier:
- class_methods - class_methods
Metrics/AbcSize: Metrics/AbcSize:
Max: 115 Max: 34 # RuboCop default 17
Exclude: Exclude:
- 'lib/mastodon/*_cli.rb' - 'lib/**/*cli*.rb'
- db/*migrate/**/*
- lib/paperclip/color_extractor.rb
- app/workers/scheduler/follow_recommendations_scheduler.rb
- app/services/activitypub/fetch*_service.rb
- lib/paperclip/**/*
CountRepeatedAttributes: false
AllowedMethods:
- update_media_attachments!
- account_link_to
- attempt_oembed
- build_crutches
- calculate_scores
- cc
- dump_actor!
- filter_from_home?
- hydrate
- import_bookmarks!
- import_relationships!
- initialize
- link_to_mention
- log_target
- matches_time_window?
- parse_metadata
- perform_statuses_search!
- privatize_media_attachments!
- process_update
- publish_media_attachments!
- remotable_attachment
- render_initial_state
- render_with_cache
- searchable_by
- self.cached_filters_for
- set_fetchable_attributes!
- signed_request_actor
- statuses_to_delete
- update_poll!
Metrics/BlockLength: Metrics/BlockLength:
Max: 55 Max: 55
Exclude: Exclude:
- 'lib/tasks/**/*'
- 'lib/mastodon/*_cli.rb' - 'lib/mastodon/*_cli.rb'
CountComments: false
CountAsOne: [array, heredoc]
AllowedMethods:
- task
- namespace
- class_methods
- included
Metrics/BlockNesting: Metrics/BlockNesting:
Max: 3 Max: 3
@ -85,34 +133,144 @@ Metrics/BlockNesting:
Metrics/ClassLength: Metrics/ClassLength:
CountComments: false CountComments: false
Max: 500 Max: 500
CountAsOne: [array, heredoc]
Exclude: Exclude:
- 'lib/mastodon/*_cli.rb' - 'lib/mastodon/*_cli.rb'
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 25 Max: 12
Exclude: Exclude:
- 'lib/mastodon/*_cli.rb' - lib/mastodon/*cli*.rb
- db/*migrate/**/*
AllowedMethods:
- attempt_oembed
- blocked?
- build_crutches
- calculate_scores
- cc
- discover_endpoint!
- filter_from_home?
- hydrate
- klass
- link_to_mention
- log_target
- matches_time_window?
- patch_for_forwarding!
- preprocess_attributes!
- process_update
- remotable_attachment
- scan_text!
- self.cached_filters_for
- set_fetchable_attributes!
- setup_redis_env_url
- update_media_attachments!
Layout/LineLength: Layout/LineLength:
Max: 140 # RuboCop default 120
AllowHeredoc: true
AllowURI: true AllowURI: true
Enabled: false IgnoreCopDirectives: true
AllowedPatterns:
# Allow comments to be long lines
- !ruby/regexp / \# .*$/
- !ruby/regexp /^\# .*$/
Exclude:
- lib/**/*cli*.rb
- db/*migrate/**/*
- db/seeds/**/*
Metrics/MethodLength: Metrics/MethodLength:
CountComments: false CountComments: false
Max: 65 CountAsOne: [array, heredoc]
Max: 25 # RuboCop default 10
Exclude: Exclude:
- 'lib/mastodon/*_cli.rb' - 'lib/mastodon/*_cli.rb'
AllowedMethods:
- account_link_to
- attempt_oembed
- body_with_limit
- build_crutches
- cached_filters_for
- calculate_scores
- check_webfinger!
- clean_feeds!
- collection_items
- collection_presenter
- copy_account_notes!
- deduplicate_accounts!
- deduplicate_conversations!
- deduplicate_local_accounts!
- deduplicate_statuses!
- deduplicate_tags!
- deduplicate_users!
- discover_endpoint!
- extract_extra_uris_with_indices
- extract_hashtags_with_indices
- extract_mentions_or_lists_with_indices
- filter_from_home?
- from_elasticsearch
- handle_explicit_update!
- handle_mark_as_sensitive!
- hsl_to_rgb
- import_bookmarks!
- import_domain_blocks!
- import_relationships!
- ldap_options
- matches_time_window?
- outbox_presenter
- pam_get_user
- parallelize_with_progress
- parse_and_transform
- patch_for_forwarding!
- populate_home
- post_process_style
- preload_cache_collection_target_statuses
- privatize_media_attachments!
- provides_callback_for
- publish_media_attachments!
- relevant_account_timestamp
- remotable_attachment
- rgb_to_hsl
- rss_status_content_format
- set_fetchable_attributes!
- setup_redis_env_url
- signed_request_actor
- to_preview_card_attributes
- upgrade_storage_filesystem
- upgrade_storage_s3
- user_settings_params
- hydrate
- cc
- self_destruct
Metrics/ModuleLength: Metrics/ModuleLength:
CountComments: false CountComments: false
Max: 200 Max: 200
CountAsOne: [array, heredoc]
Metrics/ParameterLists: Metrics/ParameterLists:
Max: 5 Max: 5 # RuboCop default 5
CountKeywordArgs: true CountKeywordArgs: true # RuboCop default true
MaxOptionalParameters: 3 # RuboCop default 3
Exclude:
- app/models/concerns/account_interactions.rb
- app/services/activitypub/fetch_remote_account_service.rb
- app/services/activitypub/fetch_remote_actor_service.rb
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 25 Max: 16 # RuboCop default 8
AllowedMethods:
- attempt_oembed
- build_crutches
- calculate_scores
- deduplicate_users!
- discover_endpoint!
- filter_from_home?
- hydrate
- patch_for_forwarding!
- process_update
- remove_orphans
- update_media_attachments!
Naming/MemoizedInstanceVariableName: Naming/MemoizedInstanceVariableName:
Enabled: false Enabled: false
@ -267,9 +425,6 @@ Style/PercentLiteralDelimiters:
Style/PerlBackrefs: Style/PerlBackrefs:
AutoCorrect: false AutoCorrect: false
Style/RedundantAssignment:
Enabled: false
Style/RedundantFetchBlock: Style/RedundantFetchBlock:
Enabled: true Enabled: true
@ -292,7 +447,7 @@ Style/RegexpLiteral:
Enabled: false Enabled: false
Style/RescueStandardError: Style/RescueStandardError:
Enabled: false Enabled: true
Style/SignalException: Style/SignalException:
Enabled: false Enabled: false
@ -311,3 +466,14 @@ Style/TrailingCommaInHashLiteral:
Style/UnpackFirst: Style/UnpackFirst:
Enabled: false Enabled: false
RSpec/ScatteredSetup:
Enabled: false
RSpec/ImplicitExpect:
Enabled: false
RSpec/NamedSubject:
Enabled: false
RSpec/DescribeClass:
Enabled: false
RSpec/LetSetup:
Enabled: false

@ -1,26 +1,4 @@
ffmpeg ffmpeg
libicu[0-9][0-9]
libicu-dev
libidn12
libidn-dev
libpq-dev libpq-dev
libxdamage1 libxdamage1
libxfixes3 libxfixes3
zlib1g-dev
libcairo2
libcroco3
libdatrie1
libgdk-pixbuf2.0-0
libgraphite2-3
libharfbuzz0b
libpango-1.0-0
libpangocairo-1.0-0
libpangoft2-1.0-0
libpixman-1-0
librsvg2-2
libthai-data
libthai0
libvpx[5-9]
libxcb-render0
libxcb-shm0
libxrender1

@ -15,7 +15,8 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /opt/mastodon WORKDIR /opt/mastodon
COPY Gemfile* package.json yarn.lock /opt/mastodon/ COPY Gemfile* package.json yarn.lock /opt/mastodon/
RUN apt update && \ # hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \ apt-get install -y --no-install-recommends build-essential \
ca-certificates \ ca-certificates \
git \ git \
@ -50,10 +51,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND="noninteractive" \ ENV DEBIAN_FRONTEND="noninteractive" \
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin"
# Ignoreing 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 && \ RUN apt-get update && \
echo "Etc/UTC" > /etc/localtime && \ echo "Etc/UTC" > /etc/localtime && \
groupadd -g "${GID}" mastodon && \ groupadd -g "${GID}" mastodon && \
useradd -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \ useradd -l -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \
apt-get -y --no-install-recommends install whois \ apt-get -y --no-install-recommends install whois \
wget \ wget \
procps \ procps \

@ -107,6 +107,10 @@ group :development, :test do
gem 'pry-byebug', '~> 3.10' gem 'pry-byebug', '~> 3.10'
gem 'pry-rails', '~> 0.3' gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 5.1' gem 'rspec-rails', '~> 5.1'
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'rubocop', require: false
end end
group :production, :test do group :production, :test do
@ -117,13 +121,14 @@ group :test do
gem 'capybara', '~> 3.38' gem 'capybara', '~> 3.38'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 3.0' gem 'faker', '~> 3.0'
gem 'json-schema', '~> 3.0'
gem 'microformats', '~> 4.4' gem 'microformats', '~> 4.4'
gem 'rack-test', '~> 2.0'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec_junit_formatter', '~> 0.6'
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.18' gem 'webmock', '~> 3.18'
gem 'rspec_junit_formatter', '~> 0.6'
gem 'rack-test', '~> 2.0'
end end
group :development do group :development do
@ -135,8 +140,6 @@ group :development do
gem 'letter_opener', '~> 1.8' gem 'letter_opener', '~> 1.8'
gem 'letter_opener_web', '~> 2.0' gem 'letter_opener_web', '~> 2.0'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 1.30', require: false
gem 'rubocop-rails', '~> 2.15', require: false
gem 'brakeman', '~> 5.4', require: false gem 'brakeman', '~> 5.4', require: false
gem 'bundler-audit', '~> 0.9', require: false gem 'bundler-audit', '~> 0.9', require: false

@ -346,6 +346,8 @@ GEM
json-ld-preloaded (3.2.2) json-ld-preloaded (3.2.2)
json-ld (~> 3.2) json-ld (~> 3.2)
rdf (~> 3.2) rdf (~> 3.2)
json-schema (3.0.0)
addressable (>= 2.8)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.4.1) jwt (2.4.1)
kaminari (1.2.2) kaminari (1.2.2)
@ -587,21 +589,27 @@ GEM
rspec-support (3.11.1) rspec-support (3.11.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.30.1) rubocop (1.39.0)
json (~> 2.3)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.1.0.0) parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.18.0, < 2.0) rubocop-ast (>= 1.23.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.18.0) rubocop-ast (1.23.0)
parser (>= 3.1.1.0) parser (>= 3.1.1.0)
rubocop-rails (2.15.0) rubocop-performance (1.15.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.17.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.15.0)
rubocop (~> 1.33)
ruby-progressbar (1.11.0) ruby-progressbar (1.11.0)
ruby-saml (1.13.0) ruby-saml (1.13.0)
nokogiri (>= 1.10.5) nokogiri (>= 1.10.5)
@ -794,6 +802,7 @@ DEPENDENCIES
idn-ruby idn-ruby
json-ld json-ld
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)
json-schema (~> 3.0)
kaminari (~> 1.2) kaminari (~> 1.2)
kt-paperclip (~> 7.1) kt-paperclip (~> 7.1)
letter_opener (~> 1.8) letter_opener (~> 1.8)
@ -843,8 +852,10 @@ DEPENDENCIES
rspec-rails (~> 5.1) rspec-rails (~> 5.1)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.6) rspec_junit_formatter (~> 0.6)
rubocop (~> 1.30) rubocop
rubocop-rails (~> 2.15) rubocop-performance
rubocop-rails
rubocop-rspec
ruby-progressbar (~> 1.11) ruby-progressbar (~> 1.11)
sanitize (~> 6.0) sanitize (~> 6.0)
scenic (~> 1.6) scenic (~> 1.6)
@ -869,3 +880,9 @@ DEPENDENCIES
webpacker (~> 5.4) webpacker (~> 5.4)
webpush! webpush!
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.2.33

@ -55,12 +55,8 @@ module Admin
def update def update
authorize :domain_block, :update? authorize :domain_block, :update?
@domain_block.update(update_params) if @domain_block.update(update_params)
DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
severity_changed = @domain_block.severity_changed?
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
log_action :update, @domain_block log_action :update, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else else

@ -3,7 +3,7 @@
module Admin module Admin
class RelaysController < BaseController class RelaysController < BaseController
before_action :set_relay, except: [:index, :new, :create] before_action :set_relay, except: [:index, :new, :create]
before_action :require_signatures_enabled!, only: [:new, :create, :enable] before_action :warn_signatures_not_enabled!, only: [:new, :create, :enable]
def index def index
authorize :relay, :update? authorize :relay, :update?
@ -56,8 +56,8 @@ module Admin
params.require(:relay).permit(:inbox_url) params.require(:relay).permit(:inbox_url)
end end
def require_signatures_enabled! def warn_signatures_not_enabled!
redirect_to admin_relays_path, alert: I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode? flash.now[:error] = I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
end end
end end
end end

@ -16,6 +16,26 @@ class Api::BaseController < ApplicationController
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
content_security_policy do |p|
# Set every directive that does not have a fallback
p.default_src :none
p.frame_ancestors :none
p.form_action :none
# Disable every directive with a fallback to cut on response size
p.base_uri false
p.font_src false
p.img_src false
p.style_src false
p.media_src false
p.frame_src false
p.manifest_src false
p.connect_src false
p.script_src false
p.child_src false
p.worker_src false
end
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422 render json: { error: e.to_s }, status: 422
end end

@ -40,10 +40,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
def update def update
authorize @domain_block, :update? authorize @domain_block, :update?
@domain_block.update(domain_block_params) @domain_block.update!(domain_block_params)
severity_changed = @domain_block.severity_changed? DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
@domain_block.save!
DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
log_action :update, @domain_block log_action :update, @domain_block
render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer
end end

@ -40,7 +40,7 @@ class Api::V1::NotificationsController < Api::BaseController
private private
def load_notifications def load_notifications
notifications = browserable_account_notifications.includes(from_account: :account_stat).to_a_paginated_by_id( notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_paginated_by_id(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT), limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) )

@ -11,6 +11,8 @@ class Auth::PasswordsController < Devise::PasswordsController
super do |resource| super do |resource|
if resource.errors.empty? if resource.errors.empty?
resource.session_activations.destroy_all resource.session_activations.destroy_all
resource.revoke_access!
end end
end end
end end

@ -57,8 +57,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def configure_sign_up_params def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |u| devise_parameter_sanitizer.permit(:sign_up) do |user_params|
u.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
end end
end end

@ -58,7 +58,7 @@ module RateLimitHeaders
end end
def api_throttle_data def api_throttle_data
most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] - v[:count] } most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_key, value| value[:limit] - value[:count] }
request.env['rack.attack.throttle_data'][most_limited_type] request.env['rack.attack.throttle_data'][most_limited_type]
end end

@ -28,8 +28,8 @@ module SignatureVerification
end end
class SignatureParamsTransformer < Parslet::Transform class SignatureParamsTransformer < Parslet::Transform
rule(params: subtree(:p)) do rule(params: subtree(:param)) do
(p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val } (param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value }
end end
rule(param: { key: simple(:key), value: simple(:val) }) do rule(param: { key: simple(:key), value: simple(:val) }) do

@ -63,7 +63,7 @@ class FollowerAccountsController < ApplicationController
if page_requested? if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account, page: params.fetch(:page, 1)), id: account_followers_url(@account, page: params.fetch(:page, 1)),
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
part_of: account_followers_url(@account), part_of: account_followers_url(@account),
next: next_page_url, next: next_page_url,
prev: prev_page_url, prev: prev_page_url,

@ -66,7 +66,7 @@ class FollowingAccountsController < ApplicationController
id: account_following_index_url(@account, page: params.fetch(:page, 1)), id: account_following_index_url(@account, page: params.fetch(:page, 1)),
type: :ordered, type: :ordered,
size: @account.following_count, size: @account.following_count,
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
part_of: account_following_index_url(@account), part_of: account_following_index_url(@account),
next: next_page_url, next: next_page_url,
prev: prev_page_url prev: prev_page_url

@ -13,8 +13,8 @@ class MediaController < ApplicationController
before_action :allow_iframing, only: :player before_action :allow_iframing, only: :player
before_action :set_pack, only: :player before_action :set_pack, only: :player
content_security_policy only: :player do |p| content_security_policy only: :player do |policy|
p.frame_ancestors(false) policy.frame_ancestors(false)
end end
def show def show

@ -17,8 +17,8 @@ class StatusesController < ApplicationController
skip_around_action :set_locale, if: -> { request.format == :json } skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode? skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
content_security_policy only: :embed do |p| content_security_policy only: :embed do |policy|
p.frame_ancestors(false) policy.frame_ancestors(false)
end end
def show def show

@ -65,7 +65,7 @@ class TagsController < ApplicationController
id: tag_url(@tag), id: tag_url(@tag),
type: :ordered, type: :ordered,
size: @tag.statuses.count, size: @tag.statuses.count,
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } items: @statuses.map { |status| ActivityPub::TagManager.instance.uri_for(status) }
) )
end end
end end

@ -23,19 +23,28 @@ module FormattingHelper
before_html = begin before_html = begin
if status.spoiler_text? if status.spoiler_text?
"<p><strong>#{I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)}</strong> #{h(status.spoiler_text)}</p><hr />" tag.p do
else tag.strong do
'' I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)
end
status.spoiler_text
end + tag.hr
end end
end.html_safe # rubocop:disable Rails/OutputSafety end
after_html = begin after_html = begin
if status.preloadable_poll if status.preloadable_poll
"<p>#{status.preloadable_poll.options.map { |o| "<input type=#{status.preloadable_poll.multiple? ? 'checkbox' : 'radio'} disabled /> #{h(o)}" }.join('<br />')}</p>" tag.p do
else safe_join(
'' status.preloadable_poll.options.map do |o|
tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true)
end,
tag.br
)
end
end end
end.html_safe # rubocop:disable Rails/OutputSafety end
prerender_custom_emojis( prerender_custom_emojis(
safe_join([before_html, html, after_html]), safe_join([before_html, html, after_html]),

@ -190,12 +190,15 @@ module LanguagesHelper
ISO_639_3 = { ISO_639_3 = {
ast: ['Asturian', 'Asturianu'].freeze, ast: ['Asturian', 'Asturianu'].freeze,
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
cnr: ['Montenegrin', 'crnogorski'].freeze,
jbo: ['Lojban', 'la .lojban.'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze,
kab: ['Kabyle', 'Taqbaylit'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze,
kmr: ['Kurmanji (Kurdish)', 'Kurmancî'].freeze, kmr: ['Kurmanji (Kurdish)', 'Kurmancî'].freeze,
ldn: ['Láadan', 'Láadan'].freeze, ldn: ['Láadan', 'Láadan'].freeze,
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
sco: ['Scots', 'Scots'].freeze, sco: ['Scots', 'Scots'].freeze,
sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze,
smj: ['Lule Sami', 'Julevsámegiella'].freeze,
tok: ['Toki Pona', 'toki pona'].freeze, tok: ['Toki Pona', 'toki pona'].freeze,
zba: ['Balaibalan', 'باليبلن'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze,
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,

@ -21,7 +21,7 @@ module StatusesHelper
def media_summary(status) def media_summary(status)
attachments = { image: 0, video: 0, audio: 0 } attachments = { image: 0, video: 0, audio: 0 }
status.media_attachments.each do |media| status.ordered_media_attachments.each do |media|
if media.video? if media.video?
attachments[:video] += 1 attachments[:video] += 1
elsif media.audio? elsif media.audio?

@ -1,2 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon" style="color:#fff" /></svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -7,5 +7,5 @@
<stop stop-color="#6364FF"/> <stop stop-color="#6364FF"/>
<stop offset="1" stop-color="#563ACC"/> <stop offset="1" stop-color="#563ACC"/>
</linearGradient> </linearGradient>
</defs></symbol><use xlink:href="#logo-symbol-wordmark" style="color:#fff"/> </defs></symbol><use xlink:href="#logo-symbol-wordmark"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

@ -102,7 +102,7 @@ export const addReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(addReactionRequest(announcementId, name, alreadyAdded)); dispatch(addReactionRequest(announcementId, name, alreadyAdded));
} }
api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
}).catch(err => { }).catch(err => {
if (!alreadyAdded) { if (!alreadyAdded) {
@ -136,7 +136,7 @@ export const addReactionFail = (announcementId, name, error) => ({
export const removeReaction = (announcementId, name) => (dispatch, getState) => { export const removeReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(announcementId, name)); dispatch(removeReactionRequest(announcementId, name));
api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
dispatch(removeReactionSuccess(announcementId, name)); dispatch(removeReactionSuccess(announcementId, name));
}).catch(err => { }).catch(err => {
dispatch(removeReactionFail(announcementId, name, err)); dispatch(removeReactionFail(announcementId, name, err));

@ -246,12 +246,13 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) { if (publicStatus && isRemote) {
if (isRemote) { menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') }); }
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy }); menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }

@ -23,7 +23,9 @@ export const store = configureStore();
const hydrateAction = hydrateStore(initialState); const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction); store.dispatch(hydrateAction);
store.dispatch(fetchCustomEmojis()); if (initialState.meta.me) {
store.dispatch(fetchCustomEmojis());
}
const createIdentityContext = state => ({ const createIdentityContext = state => ({
signedIn: !!state.meta.me, signedIn: !!state.meta.me,

@ -0,0 +1,37 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon';
export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account, onAuthorize, onReject } = this.props;
return (
<div className='follow-request-banner'>
<div className='follow-request-banner__message'>
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} />
</div>
<div className='follow-request-banner__action'>
<button type='button' className='button button-tertiary button--confirmation' onClick={onAuthorize}>
<Icon id='check' fixedWidth />
<FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
</button>
<button type='button' className='button button-tertiary button--destructive' onClick={onReject}>
<Icon id='times' fixedWidth />
<FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
</button>
</div>
</div>
);
}
}

@ -14,6 +14,7 @@ import ShortNumber from 'mastodon/components/short_number';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container'; import AccountNoteContainer from '../containers/account_note_container';
import FollowRequestNoteContainer from '../containers/follow_request_note_container';
import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
@ -311,6 +312,8 @@ class Header extends ImmutablePureComponent {
return ( return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
<div className='account__header__image'> <div className='account__header__image'>
<div className='account__header__info'> <div className='account__header__info'>
{!suspended && info} {!suspended && info}

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import FollowRequestNote from '../components/follow_request_note';
import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
const mapDispatchToProps = (dispatch, { account }) => ({
onAuthorize () {
dispatch(authorizeFollowRequest(account.get('id')));
},
onReject () {
dispatch(rejectFollowRequest(account.get('id')));
},
});
export default connect(null, mapDispatchToProps)(FollowRequestNote);

@ -104,6 +104,7 @@ export default class MediaItem extends ImmutablePureComponent {
<video <video
className='media-gallery__item-gifv-thumbnail' className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')} aria-label={attachment.get('description')}
title={attachment.get('description')}
role='application' role='application'
src={attachment.get('url')} src={attachment.get('url')}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}

@ -16,7 +16,6 @@ import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container'; import WarningContainer from '../containers/warning_container';
import LanguageDropdown from '../containers/language_dropdown_container'; import LanguageDropdown from '../containers/language_dropdown_container';
import { isMobile } from '../../../is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz'; import { length } from 'stringz';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
@ -62,14 +61,14 @@ class ComposeForm extends ImmutablePureComponent {
onChangeSpoilerText: PropTypes.func.isRequired, onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool, autoFocus: PropTypes.bool,
anyMedia: PropTypes.bool, anyMedia: PropTypes.bool,
isInReply: PropTypes.bool, isInReply: PropTypes.bool,
singleColumn: PropTypes.bool, singleColumn: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
showSearch: false, autoFocus: false,
}; };
handleChange = (e) => { handleChange = (e) => {
@ -155,7 +154,7 @@ class ComposeForm extends ImmutablePureComponent {
// - Replying to zero or one users, places the cursor at the end of the textbox. // - Replying to zero or one users, places the cursor at the end of the textbox.
// - Replying to more than one user, selects any usernames past the first; // - Replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation. // this provides a convenient shortcut to drop everyone else from the conversation.
if (this.props.focusDate !== prevProps.focusDate) { if (this.props.focusDate && this.props.focusDate !== prevProps.focusDate) {
let selectionEnd, selectionStart; let selectionEnd, selectionStart;
if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) { if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) {
@ -181,7 +180,7 @@ class ComposeForm extends ImmutablePureComponent {
} else if (this.props.spoiler !== prevProps.spoiler) { } else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) { if (this.props.spoiler) {
this.spoilerText.input.focus(); this.spoilerText.input.focus();
} else { } else if (prevProps.spoiler) {
this.autosuggestTextarea.textarea.focus(); this.autosuggestTextarea.textarea.focus();
} }
} }
@ -208,7 +207,7 @@ class ComposeForm extends ImmutablePureComponent {
} }
render () { render () {
const { intl, onPaste, showSearch } = this.props; const { intl, onPaste, autoFocus } = this.props;
const disabled = this.props.isSubmitting; const disabled = this.props.isSubmitting;
let publishText = ''; let publishText = '';
@ -258,7 +257,7 @@ class ComposeForm extends ImmutablePureComponent {
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste} onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth)} autoFocus={autoFocus}
> >
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />

@ -165,6 +165,7 @@ class PollForm extends ImmutablePureComponent {
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>

@ -123,27 +123,24 @@ class Search extends React.PureComponent {
return ( return (
<div className='search'> <div className='search'>
<label> <input
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> ref={this.setRef}
<input className='search__input'
ref={this.setRef} type='text'
className='search__input' placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
type='text' aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} value={value}
value={value} onChange={this.handleChange}
onChange={this.handleChange} onKeyUp={this.handleKeyUp}
onKeyUp={this.handleKeyUp} onFocus={this.handleFocus}
onFocus={this.handleFocus} onBlur={this.handleBlur}
onBlur={this.handleBlur} />
/>
</label>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<Icon id='search' className={hasValue ? '' : 'active'} /> <Icon id='search' className={hasValue ? '' : 'active'} />
<Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} /> <Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
</div> </div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this} container={this}>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<SearchPopout /> <SearchPopout />
</Overlay> </Overlay>
</div> </div>

@ -24,7 +24,6 @@ const mapStateToProps = state => ({
isEditing: state.getIn(['compose', 'id']) !== null, isEditing: state.getIn(['compose', 'id']) !== null,
isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']), isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isInReply: state.getIn(['compose', 'in_reply_to']) !== null, isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
}); });

@ -18,6 +18,7 @@ import Icon from 'mastodon/components/icon';
import { logOut } from 'mastodon/utils/log_out'; import { logOut } from 'mastodon/utils/log_out';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { isMobile } from '../../is_mobile';
const messages = defineMessages({ const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -115,7 +116,7 @@ class Compose extends React.PureComponent {
<div className='drawer__inner' onFocus={this.onFocus}> <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} /> <NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer /> <ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
<div className='drawer__inner__mastodon'> <div className='drawer__inner__mastodon'>
<img alt='' draggable='false' src={mascot || elephantUIPlane} /> <img alt='' draggable='false' src={mascot || elephantUIPlane} />

@ -24,16 +24,6 @@ const mapStateToProps = state => ({
isSearching: state.getIn(['search', 'submitted']) || !showTrends, isSearching: state.getIn(['search', 'submitted']) || !showTrends,
}); });
// Fix strange bug on Safari where <span> (rendered by FormattedMessage) disappears
// after clicking around Explore top bar (issue #20885).
// Removing width=100% from <a> also fixes it, as well as replacing <span> with <div>
// We're choosing to wrap span with div to keep the changes local only to this tool bar.
const WrapFormattedMessage = ({ children, ...props }) => <div><FormattedMessage {...props}>{children}</FormattedMessage></div>;
WrapFormattedMessage.propTypes = {
children: PropTypes.any,
};
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@injectIntl @injectIntl
class Explore extends React.PureComponent { class Explore extends React.PureComponent {
@ -78,12 +68,22 @@ class Explore extends React.PureComponent {
{isSearching ? ( {isSearching ? (
<SearchResults /> <SearchResults />
) : ( ) : (
<React.Fragment> <>
<div className='account__section-headline'> <div className='account__section-headline'>
<NavLink exact to='/explore'><WrapFormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink> <NavLink exact to='/explore'>
<NavLink exact to='/explore/tags'><WrapFormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink> <FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
<NavLink exact to='/explore/links'><WrapFormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink> </NavLink>
{signedIn && <NavLink exact to='/explore/suggestions'><WrapFormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>} <NavLink exact to='/explore/tags'>
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
</NavLink>
<NavLink exact to='/explore/links'>
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
</NavLink>
{signedIn && (
<NavLink exact to='/explore/suggestions'>
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='For you' />
</NavLink>
)}
</div> </div>
<Switch> <Switch>
@ -97,7 +97,7 @@ class Explore extends React.PureComponent {
<title>{intl.formatMessage(messages.title)}</title> <title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content={isSearching ? 'noindex' : 'all'} /> <meta name='robots' content={isSearching ? 'noindex' : 'all'} />
</Helmet> </Helmet>
</React.Fragment> </>
)} )}
</div> </div>
</Column> </Column>

@ -194,7 +194,7 @@ class HashtagTimeline extends React.PureComponent {
const following = tag.get('following'); const following = tag.get('following');
followButton = ( followButton = (
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}> <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} active={following} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
<Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' /> <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
</button> </button>
); );

@ -97,7 +97,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
if (this.mediaQuery.removeEventListener) { if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.handleLayoutChange); this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
} else { } else {
this.mediaQuery.removeListener(this.handleLayouteChange); this.mediaQuery.removeListener(this.handleLayoutChange);
} }
} }
} }

@ -291,11 +291,11 @@ class FocalPointModal extends ImmutablePureComponent {
let descriptionLabel = null; let descriptionLabel = null;
if (media.get('type') === 'audio') { if (media.get('type') === 'audio') {
descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />; descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
} else if (media.get('type') === 'video') { } else if (media.get('type') === 'video') {
descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />; descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
} else { } else {
descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />; descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
} }
let ocrMessage = ''; let ocrMessage = '';

@ -2014,6 +2014,22 @@
{ {
"defaultMessage": "Search results", "defaultMessage": "Search results",
"id": "explore.search_results" "id": "explore.search_results"
},
{
"defaultMessage": "Posts",
"id": "explore.trending_statuses"
},
{
"defaultMessage": "Hashtags",
"id": "explore.trending_tags"
},
{
"defaultMessage": "News",
"id": "explore.trending_links"
},
{
"defaultMessage": "For you",
"id": "explore.suggested_follows"
} }
], ],
"path": "app/javascript/mastodon/features/explore/index.json" "path": "app/javascript/mastodon/features/explore/index.json"
@ -3918,15 +3934,15 @@
"id": "confirmations.discard_edit_media.confirm" "id": "confirmations.discard_edit_media.confirm"
}, },
{ {
"defaultMessage": "Describe for people with hearing loss", "defaultMessage": "Describe for people who are deaf or hard of hearing",
"id": "upload_form.audio_description" "id": "upload_form.audio_description"
}, },
{ {
"defaultMessage": "Describe for people with hearing loss or visual impairment", "defaultMessage": "Describe for people who are deaf, hard of hearing, blind or have low vision",
"id": "upload_form.video_description" "id": "upload_form.video_description"
}, },
{ {
"defaultMessage": "Describe for the visually impaired", "defaultMessage": "Describe for people who are blind or have low vision",
"id": "upload_form.description" "id": "upload_form.description"
}, },
{ {

@ -239,7 +239,11 @@
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard", "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
"errors.unexpected_crash.report_issue": "Report issue", "errors.unexpected_crash.report_issue": "Report issue",
"explore.search_results": "Search results", "explore.search_results": "Search results",
"explore.suggested_follows": "For you",
"explore.title": "Explore", "explore.title": "Explore",
"explore.trending_links": "News",
"explore.trending_statuses": "Posts",
"explore.trending_tags": "Hashtags",
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.", "filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
"filter_modal.added.context_mismatch_title": "Context mismatch!", "filter_modal.added.context_mismatch_title": "Context mismatch!",
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.", "filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
@ -462,6 +466,7 @@
"refresh": "Refresh", "refresh": "Refresh",
"regeneration_indicator.label": "Loading…", "regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!", "regeneration_indicator.sublabel": "Your home feed is being prepared!",
"relative_format.today": "Today at {time}",
"relative_time.days": "{number}d", "relative_time.days": "{number}d",
"relative_time.full.days": "{number, plural, one {# day} other {# days}} ago", "relative_time.full.days": "{number, plural, one {# day} other {# days}} ago",
"relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago", "relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago",
@ -622,13 +627,13 @@
"upload_button.label": "Add images, a video or an audio file", "upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.", "upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.", "upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss", "upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
"upload_form.description": "Describe for the visually impaired", "upload_form.description": "Describe for people who are blind or have low vision",
"upload_form.description_missing": "No description added", "upload_form.description_missing": "No description added",
"upload_form.edit": "Edit", "upload_form.edit": "Edit",
"upload_form.thumbnail": "Change thumbnail", "upload_form.thumbnail": "Change thumbnail",
"upload_form.undo": "Delete", "upload_form.undo": "Delete",
"upload_form.video_description": "Describe for people with hearing loss or visual impairment", "upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",
"upload_modal.analyzing_picture": "Analyzing picture…", "upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply", "upload_modal.apply": "Apply",
"upload_modal.applying": "Applying…", "upload_modal.applying": "Applying…",

@ -431,6 +431,8 @@ export default function compose(state = initialState, action) {
case TIMELINE_DELETE: case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) { if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null); return state.set('in_reply_to', null);
} else if (action.id === state.get('id')) {
return state.set('id', null);
} else { } else {
return state; return state;
} }

@ -1,3 +1,6 @@
import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
import { import {
ACCOUNT_FOLLOW_SUCCESS, ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_REQUEST, ACCOUNT_FOLLOW_REQUEST,
@ -12,6 +15,8 @@ import {
ACCOUNT_PIN_SUCCESS, ACCOUNT_PIN_SUCCESS,
ACCOUNT_UNPIN_SUCCESS, ACCOUNT_UNPIN_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS, RELATIONSHIPS_FETCH_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
DOMAIN_BLOCK_SUCCESS, DOMAIN_BLOCK_SUCCESS,
@ -44,6 +49,12 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) { export default function relationships(state = initialState, action) {
switch(action.type) { switch(action.type) {
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
case FOLLOW_REQUEST_REJECT_SUCCESS:
return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
case ACCOUNT_FOLLOW_REQUEST: case ACCOUNT_FOLLOW_REQUEST:
return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL: case ACCOUNT_FOLLOW_FAIL:

@ -46,6 +46,18 @@ function main() {
minute: 'numeric', minute: 'numeric',
}); });
const dateFormat = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeFormat: false,
});
const timeFormat = new Intl.DateTimeFormat(locale, {
timeStyle: 'short',
hour12: false,
});
[].forEach.call(document.querySelectorAll('.emojify'), (content) => { [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
content.innerHTML = emojify(content.innerHTML); content.innerHTML = emojify(content.innerHTML);
}); });
@ -58,6 +70,32 @@ function main() {
content.textContent = formattedDate; content.textContent = formattedDate;
}); });
const isToday = date => {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
};
const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
const datetime = new Date(content.getAttribute('datetime'));
let formattedContent;
if (isToday(datetime)) {
const formattedTime = timeFormat.format(datetime);
formattedContent = todayFormat.format({ time: formattedTime });
} else {
formattedContent = dateFormat.format(datetime);
}
content.title = formattedContent;
content.textContent = formattedContent;
});
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => { [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
const datetime = new Date(content.getAttribute('datetime')); const datetime = new Date(content.getAttribute('datetime'));
const now = new Date(); const now = new Date();

@ -1682,7 +1682,7 @@ a.sparkline {
min-height: 100%; min-height: 100%;
a { a {
text: &highlight-text-color; color: $highlight-text-color;
text-decoration: none; text-decoration: none;
&:hover { &:hover {

@ -166,6 +166,30 @@
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
} }
&.button--confirmation {
color: $valid-value-color;
border-color: $valid-value-color;
&:active,
&:focus,
&:hover {
background: $valid-value-color;
color: $primary-text-color;
}
}
&.button--destructive {
color: $error-value-color;
border-color: $error-value-color;
&:active,
&:focus,
&:hover {
background: $error-value-color;
color: $primary-text-color;
}
}
} }
&.button--block { &.button--block {
@ -2474,8 +2498,7 @@ $ui-header-height: 55px;
height: calc(100% - 10px) !important; height: calc(100% - 10px) !important;
} }
.getting-started__wrapper, .getting-started__wrapper {
.search {
margin-bottom: 10px; margin-bottom: 10px;
} }
@ -2528,7 +2551,7 @@ $ui-header-height: 55px;
} }
} }
.ui__header { .layout-single-column .ui__header {
display: flex; display: flex;
background: $ui-base-color; background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
@ -4671,6 +4694,7 @@ a.status-card.compact:hover {
} }
.search { .search {
margin-bottom: 10px;
position: relative; position: relative;
} }
@ -6722,7 +6746,8 @@ noscript {
} }
} }
.moved-account-banner { .moved-account-banner,
.follow-request-banner {
padding: 20px; padding: 20px;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
display: flex; display: flex;
@ -6745,6 +6770,7 @@ noscript {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
width: 100%;
} }
.detailed-status__display-name { .detailed-status__display-name {
@ -6752,6 +6778,10 @@ noscript {
} }
} }
.follow-request-banner .button {
width: 100%;
}
.column-inline-form { .column-inline-form {
padding: 15px; padding: 15px;
display: flex; display: flex;
@ -7021,7 +7051,6 @@ noscript {
display: block; display: block;
flex: 0 0 auto; flex: 0 0 auto;
width: 94px; width: 94px;
margin-left: -2px;
.account__avatar { .account__avatar {
background: darken($ui-base-color, 8%); background: darken($ui-base-color, 8%);
@ -7038,6 +7067,7 @@ noscript {
padding-top: 10px; padding-top: 10px;
gap: 8px; gap: 8px;
overflow: hidden; overflow: hidden;
margin-left: -2px; // aligns the pfp with content below
&__buttons { &__buttons {
display: flex; display: flex;
@ -7666,7 +7696,7 @@ noscript {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-left: 2px solid $highlight-text-color; border-left: 4px solid $highlight-text-color;
pointer-events: none; pointer-events: none;
} }
} }

@ -1,5 +1,5 @@
.modal-layout { .modal-layout {
background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}"/></svg>') repeat-x bottom fixed; background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>') repeat-x bottom fixed;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;

@ -39,6 +39,8 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
margin: -3px 0 0; margin: -3px 0 0;
margin-left: 0.075em;
margin-right: 0.075em;
} }
p { p {

@ -34,6 +34,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
end end
def compatible_version? def compatible_version?
return false if running_version.nil?
Gem::Version.new(running_version) >= Gem::Version.new(required_version) Gem::Version.new(running_version) >= Gem::Version.new(required_version)
end end
end end

@ -30,7 +30,8 @@ class Request
@verb = verb @verb = verb
@url = Addressable::URI.parse(url).normalize @url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client) @http_client = options.delete(:http_client)
@options = options.merge(socket_class: use_proxy? ? ProxySocket : Socket) @allow_local = options.delete(:allow_local)
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
@options = @options.merge(proxy_url) if use_proxy? @options = @options.merge(proxy_url) if use_proxy?
@headers = {} @headers = {}

@ -70,7 +70,7 @@ class StatusReachFinder
def followers_inboxes def followers_inboxes
if @status.in_reply_to_local_account? && distributable? if @status.in_reply_to_local_account? && distributable?
@status.account.followers.or(@status.thread.account.followers).inboxes @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).inboxes
elsif @status.direct_visibility? || @status.limited_visibility? elsif @status.direct_visibility? || @status.limited_visibility?
[] []
else else

@ -27,7 +27,7 @@ class TranslationService::LibreTranslate < TranslationService
def request(text, source_language, target_language) def request(text, source_language, target_language)
body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key) body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
req = Request.new(:post, "#{@base_url}/translate", body: body) req = Request.new(:post, "#{@base_url}/translate", body: body, allow_local: true)
req.add_headers('Content-Type': 'application/json') req.add_headers('Content-Type': 'application/json')
req req
end end

@ -339,9 +339,15 @@ class Account < ApplicationRecord
def save_with_optional_media! def save_with_optional_media!
save! save!
rescue ActiveRecord::RecordInvalid rescue ActiveRecord::RecordInvalid => e
self.avatar = nil errors = e.record.errors.errors
self.header = nil errors.each do |err|
if err.attribute == :avatar
self.avatar = nil
elsif err.attribute == :header
self.header = nil
end
end
save! save!
end end

@ -81,7 +81,7 @@ class AccountFilter
when 'suspended' when 'suspended'
Account.suspended Account.suspended
when 'disabled' when 'disabled'
accounts_with_users.merge(User.disabled) accounts_with_users.merge(User.disabled).without_suspended
when 'silenced' when 'silenced'
Account.silenced Account.silenced
when 'sensitized' when 'sensitized'

@ -44,6 +44,10 @@ module AccountInteractions
end end
end end
def requested_by_map(target_account_ids, account_id)
follow_mapping(FollowRequest.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
end
def endorsed_map(target_account_ids, account_id) def endorsed_map(target_account_ids, account_id)
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id) follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
end end

@ -210,6 +210,8 @@ class MediaAttachment < ApplicationRecord
default_scope { order(id: :asc) } default_scope { order(id: :asc) }
attr_accessor :skip_download
def local? def local?
remote_url.blank? remote_url.blank?
end end

@ -386,6 +386,15 @@ class User < ApplicationRecord
super super
end end
def revoke_access!
Doorkeeper::AccessGrant.by_resource_owner(self).update_all(revoked_at: Time.now.utc)
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
batch.update_all(revoked_at: Time.now.utc)
Web::PushSubscription.where(access_token_id: batch).delete_all
end
end
def reset_password! def reset_password!
# First, change password to something random and deactivate all sessions # First, change password to something random and deactivate all sessions
transaction do transaction do
@ -394,12 +403,7 @@ class User < ApplicationRecord
end end
# Then, remove all authorized applications and connected push subscriptions # Then, remove all authorized applications and connected push subscriptions
Doorkeeper::AccessGrant.by_resource_owner(self).in_batches.update_all(revoked_at: Time.now.utc) revoke_access!
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
batch.update_all(revoked_at: Time.now.utc)
Web::PushSubscription.where(access_token_id: batch).delete_all
end
# Finally, send a reset password prompt to the user # Finally, send a reset password prompt to the user
send_reset_password_instructions send_reset_password_instructions

@ -2,7 +2,7 @@
class AccountRelationshipsPresenter class AccountRelationshipsPresenter
attr_reader :following, :followed_by, :blocking, :blocked_by, attr_reader :following, :followed_by, :blocking, :blocked_by,
:muting, :requested, :domain_blocking, :muting, :requested, :requested_by, :domain_blocking,
:endorsed, :account_note :endorsed, :account_note
def initialize(account_ids, current_account_id, **options) def initialize(account_ids, current_account_id, **options)
@ -15,6 +15,7 @@ class AccountRelationshipsPresenter
@blocked_by = cached[:blocked_by].merge(Account.blocked_by_map(@uncached_account_ids, @current_account_id)) @blocked_by = cached[:blocked_by].merge(Account.blocked_by_map(@uncached_account_ids, @current_account_id))
@muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id)) @muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id)) @requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
@requested_by = cached[:requested_by].merge(Account.requested_by_map(@uncached_account_ids, @current_account_id))
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id)) @domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id)) @endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
@account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id)) @account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
@ -27,6 +28,7 @@ class AccountRelationshipsPresenter
@blocked_by.merge!(options[:blocked_by_map] || {}) @blocked_by.merge!(options[:blocked_by_map] || {})
@muting.merge!(options[:muting_map] || {}) @muting.merge!(options[:muting_map] || {})
@requested.merge!(options[:requested_map] || {}) @requested.merge!(options[:requested_map] || {})
@requested_by.merge!(options[:requested_by_map] || {})
@domain_blocking.merge!(options[:domain_blocking_map] || {}) @domain_blocking.merge!(options[:domain_blocking_map] || {})
@endorsed.merge!(options[:endorsed_map] || {}) @endorsed.merge!(options[:endorsed_map] || {})
@account_note.merge!(options[:account_note_map] || {}) @account_note.merge!(options[:account_note_map] || {})
@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
blocked_by: {}, blocked_by: {},
muting: {}, muting: {},
requested: {}, requested: {},
requested_by: {},
domain_blocking: {}, domain_blocking: {},
endorsed: {}, endorsed: {},
account_note: {}, account_note: {},
@ -73,6 +76,7 @@ class AccountRelationshipsPresenter
blocked_by: { account_id => blocked_by[account_id] }, blocked_by: { account_id => blocked_by[account_id] },
muting: { account_id => muting[account_id] }, muting: { account_id => muting[account_id] },
requested: { account_id => requested[account_id] }, requested: { account_id => requested[account_id] },
requested_by: { account_id => requested_by[account_id] },
domain_blocking: { account_id => domain_blocking[account_id] }, domain_blocking: { account_id => domain_blocking[account_id] },
endorsed: { account_id => endorsed[account_id] }, endorsed: { account_id => endorsed[account_id] },
account_note: { account_id => account_note[account_id] }, account_note: { account_id => account_note[account_id] },

@ -30,7 +30,7 @@ class InitialStateSerializer < ActiveModel::Serializer
streaming_api_base_url: Rails.configuration.x.streaming_api_base_url, streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
access_token: object.token, access_token: object.token,
locale: I18n.locale, locale: I18n.locale,
domain: instance_presenter.domain, domain: Addressable::IDNA.to_unicode(instance_presenter.domain),
title: instance_presenter.title, title: instance_presenter.title,
admin: object.admin&.id&.to_s, admin: object.admin&.id&.to_s,
search_enabled: Chewy.enabled?, search_enabled: Chewy.enabled?,

@ -2,8 +2,8 @@
class REST::RelationshipSerializer < ActiveModel::Serializer class REST::RelationshipSerializer < ActiveModel::Serializer
attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by, attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by,
:blocking, :blocked_by, :muting, :muting_notifications, :requested, :blocking, :blocked_by, :muting, :muting_notifications,
:domain_blocking, :endorsed, :note :requested, :requested_by, :domain_blocking, :endorsed, :note
def id def id
object.id.to_s object.id.to_s
@ -54,6 +54,10 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
instance_options[:relationships].requested[object.id] ? true : false instance_options[:relationships].requested[object.id] ? true : false
end end
def requested_by
instance_options[:relationships].requested_by[object.id] ? true : false
end
def domain_blocking def domain_blocking
instance_options[:relationships].domain_blocking[object.id] || false instance_options[:relationships].domain_blocking[object.id] || false
end end

@ -45,6 +45,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
create_edits! create_edits!
end end
download_media_files!
queue_poll_notifications! queue_poll_notifications!
next unless significant_changes? next unless significant_changes?
@ -66,12 +67,12 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
def update_media_attachments! def update_media_attachments!
previous_media_attachments = @status.media_attachments.to_a previous_media_attachments = @status.media_attachments.to_a
previous_media_attachments_ids = @status.ordered_media_attachment_ids || previous_media_attachments.map(&:id) previous_media_attachments_ids = @status.ordered_media_attachment_ids || previous_media_attachments.map(&:id)
next_media_attachments = [] @next_media_attachments = []
as_array(@json['attachment']).each do |attachment| as_array(@json['attachment']).each do |attachment|
media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4 next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > 4
begin begin
media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url } media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }
@ -87,34 +88,39 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
media_attachment.focus = media_attachment_parser.focus media_attachment.focus = media_attachment_parser.focus
media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url
media_attachment.blurhash = media_attachment_parser.blurhash media_attachment.blurhash = media_attachment_parser.blurhash
media_attachment.status_id = @status.id
media_attachment.skip_download = unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
media_attachment.save! media_attachment.save!
next_media_attachments << media_attachment @next_media_attachments << media_attachment
next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
begin
media_attachment.download_file! if media_attachment.remote_url_previously_changed?
media_attachment.download_thumbnail! if media_attachment.thumbnail_remote_url_previously_changed?
media_attachment.save
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
end
rescue Addressable::URI::InvalidURIError => e rescue Addressable::URI::InvalidURIError => e
Rails.logger.debug "Invalid URL in attachment: #{e}" Rails.logger.debug "Invalid URL in attachment: #{e}"
end end
end end
added_media_attachments = next_media_attachments - previous_media_attachments added_media_attachments = @next_media_attachments - previous_media_attachments
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id) @status.ordered_media_attachment_ids = @next_media_attachments.map(&:id)
@status.ordered_media_attachment_ids = next_media_attachments.map(&:id)
@status.media_attachments.reload
@media_attachments_changed = true if @status.ordered_media_attachment_ids != previous_media_attachments_ids @media_attachments_changed = true if @status.ordered_media_attachment_ids != previous_media_attachments_ids
end end
def download_media_files!
@next_media_attachments.each do |media_attachment|
next if media_attachment.skip_download
media_attachment.download_file! if media_attachment.remote_url_previously_changed?
media_attachment.download_thumbnail! if media_attachment.thumbnail_remote_url_previously_changed?
media_attachment.save
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
rescue Seahorse::Client::NetworkingError => e
Rails.logger.warn "Error storing media attachment: #{e}"
end
@status.media_attachments.reload
end
def update_poll!(allow_significant_changes: true) def update_poll!(allow_significant_changes: true)
previous_poll = @status.preloadable_poll previous_poll = @status.preloadable_poll
@previous_expires_at = previous_poll&.expires_at @previous_expires_at = previous_poll&.expires_at

@ -37,12 +37,15 @@ class PostStatusService < BaseService
schedule_status! schedule_status!
else else
process_status! process_status!
postprocess_status!
bump_potential_friendship!
end end
redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given? redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
unless scheduled?
postprocess_status!
bump_potential_friendship!
end
@status @status
end end
@ -75,9 +78,6 @@ class PostStatusService < BaseService
ApplicationRecord.transaction do ApplicationRecord.transaction do
@status = @account.statuses.create!(status_attributes) @status = @account.statuses.create!(status_attributes)
end end
process_hashtags_service.call(@status)
process_mentions_service.call(@status)
end end
def schedule_status! def schedule_status!
@ -101,6 +101,8 @@ class PostStatusService < BaseService
end end
def postprocess_status! def postprocess_status!
process_hashtags_service.call(@status)
process_mentions_service.call(@status)
Trends.tags.register(@status) Trends.tags.register(@status)
LinkCrawlWorker.perform_async(@status.id) LinkCrawlWorker.perform_async(@status.id)
DistributionWorker.perform_async(@status.id) DistributionWorker.perform_async(@status.id)

@ -76,11 +76,27 @@ class TagSearchService < BaseService
definition = TagsIndex.query(query) definition = TagsIndex.query(query)
definition = definition.filter(filter) if @options[:exclude_unreviewed] definition = definition.filter(filter) if @options[:exclude_unreviewed]
definition.limit(@limit).offset(@offset).objects.compact ensure_exact_match(definition.limit(@limit).offset(@offset).objects.compact)
rescue Faraday::ConnectionFailed, Parslet::ParseFailed rescue Faraday::ConnectionFailed, Parslet::ParseFailed
nil nil
end end
# Since the ElasticSearch Query doesn't guarantee the exact match will be the
# first result or that it will even be returned, patch the results accordingly
def ensure_exact_match(results)
return results unless @offset.nil? || @offset.zero?
normalized_query = Tag.normalize(@query)
exact_match = results.find { |tag| tag.name.downcase == normalized_query }
exact_match ||= Tag.find_normalized(normalized_query)
unless exact_match.nil?
results.delete(exact_match)
results = [exact_match] + results
end
results
end
def from_database def from_database
Tag.search_for(@query, @limit, @offset, @options) Tag.search_for(@query, @limit, @offset, @options)
end end

@ -10,7 +10,7 @@
.filter-subset.filter-subset--with-select .filter-subset.filter-subset--with-select
%strong= t('admin.accounts.moderation.title') %strong= t('admin.accounts.moderation.title')
.input.select.optional .input.select.optional
= select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all') = select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.disabled'), 'disabled'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all')
.filter-subset.filter-subset--with-select .filter-subset.filter-subset--with-select
%strong= t('admin.accounts.role') %strong= t('admin.accounts.role')
.input.select.optional .input.select.optional

@ -195,9 +195,13 @@
- if @account.suspended? - if @account.suspended?
%hr.spacer/ %hr.spacer/
%p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible') - if @account.suspension_origin_remote?
%p.muted-hint= @deletion_request.present? ? t('admin.accounts.remote_suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.remote_suspension_irreversible')
- else
%p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible')
= link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account) = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
= link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account) && @account.suspension_origin_remote?
- if @deletion_request.present? - if @deletion_request.present?
= link_to t('admin.accounts.delete'), admin_account_path(@account.id), method: :delete, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, @account) = link_to t('admin.accounts.delete'), admin_account_path(@account.id), method: :delete, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, @account)

@ -1,6 +1,9 @@
- content_for :page_title do - content_for :page_title do
= t('admin.export_domain_blocks.import.title') = t('admin.export_domain_blocks.import.title')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
%p= t('admin.export_domain_blocks.import.description_html') %p= t('admin.export_domain_blocks.import.description_html')
- if defined?(@global_private_comment) && @global_private_comment.present? - if defined?(@global_private_comment) && @global_private_comment.present?

@ -4,11 +4,8 @@
.report-notes__item__header .report-notes__item__header
%span.username %span.username
= link_to report_note.account.username, admin_account_path(report_note.account_id) = link_to report_note.account.username, admin_account_path(report_note.account_id)
%time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) } %time.relative-formatted{ datetime: report_note.created_at }
- if report_note.created_at.today? = t('admin.report_notes.created_at')
= t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
- else
= l report_note.created_at.to_date
.report-notes__item__content .report-notes__item__content
= simple_format(h(report_note.content)) = simple_format(h(report_note.content))

@ -140,11 +140,8 @@
= link_to @report.account.username, admin_account_path(@report.account_id) = link_to @report.account.username, admin_account_path(@report.account_id)
- else - else
= link_to @report.account.domain, admin_instance_path(@report.account.domain) = link_to @report.account.domain, admin_instance_path(@report.account.domain)
%time{ datetime: @report.created_at.iso8601, title: l(@report.created_at) } %time.relative-formatted{ datetime: @report.created_at.iso8601 }
- if @report.created_at.today? = t('admin.report_notes.created_at')
= t('admin.report_notes.today_at', time: l(@report.created_at, format: :time))
- else
= l @report.created_at.to_date
.report-notes__item__content .report-notes__item__content
= simple_format(h(@report.comment)) = simple_format(h(@report.comment))

@ -6,6 +6,8 @@
- unless omniauth_only? - unless omniauth_only?
= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
%h1.title= t('auth.sign_in.title', domain: site_hostname)
%p.lead= t('auth.sign_in.preamble_html', domain: site_hostname)
.fields-group .fields-group
- if use_seamless_external_login? - if use_seamless_external_login?
= f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label': t('simple_form.labels.defaults.username_or_email') }, hint: false = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label': t('simple_form.labels.defaults.username_or_email') }, hint: false

@ -110,11 +110,8 @@
.report-notes__item__header .report-notes__item__header
%span.username %span.username
= link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account) = link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account)
%time{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) } %time.relative-formatted{ datetime: @appeal.created_at.iso8601 }
- if @appeal.created_at.today? = t('admin.report_notes.created_at')
= t('admin.report_notes.today_at', time: l(@appeal.created_at, format: :time))
- else
= l @appeal.created_at.to_date
.report-notes__item__content .report-notes__item__content
= simple_format(h(@appeal.text)) = simple_format(h(@appeal.text))

@ -26,6 +26,6 @@
- if featured_tag.last_status_at.nil? - if featured_tag.last_status_at.nil?
= t('accounts.nothing_here') = t('accounts.nothing_here')
- else - else
%time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
= table_link_to 'trash', t('filters.index.delete'), settings_featured_tag_path(featured_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } = table_link_to 'trash', t('filters.index.delete'), settings_featured_tag_path(featured_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
.trends__item__current= friendly_number_to_human featured_tag.statuses_count .trends__item__current= friendly_number_to_human featured_tag.statuses_count

@ -9,7 +9,7 @@ class Scheduler::SuspendedUserCleanupScheduler
MAX_PULL_SIZE = 50 MAX_PULL_SIZE = 50
# Since account deletion is very expensive, we want to avoid # Since account deletion is very expensive, we want to avoid
# overloading the server by queing too much at once. # overloading the server by queuing too much at once.
# This job runs approximately once per 2 minutes, so with a # This job runs approximately once per 2 minutes, so with a
# value of `MAX_DELETIONS_PER_JOB` of 10, a server can # value of `MAX_DELETIONS_PER_JOB` of 10, a server can
# handle the deletion of 7200 accounts per day, provided it # handle the deletion of 7200 accounts per day, provided it

@ -93,6 +93,7 @@ module Mastodon
:fa, :fa,
:fi, :fi,
:fr, :fr,
:fy,
:ga, :ga,
:gd, :gd,
:gl, :gl,

@ -159,7 +159,7 @@ Devise.setup do |config|
# config.request_keys = [] # config.request_keys = []
# Configure which authentication keys should be case-insensitive. # Configure which authentication keys should be case-insensitive.
# These keys will be downcased upon creating or modifying a user and when used # These keys will be lowercased upon creating or modifying a user and when used
# to authenticate or find a user. Default is :email. # to authenticate or find a user. Default is :email.
config.case_insensitive_keys = [:email] config.case_insensitive_keys = [:email]

@ -149,9 +149,19 @@ en:
scopes: scopes:
admin:read: read all data on the server admin:read: read all data on the server
admin:read:accounts: read sensitive information of all accounts admin:read:accounts: read sensitive information of all accounts
admin:read:canonical_email_blocks: read sensitive information of all canonical email blocks
admin:read:domain_allows: read sensitive information of all domain allows
admin:read:domain_blocks: read sensitive information of all domain blocks
admin:read:email_domain_blocks: read sensitive information of all email domain blocks
admin:read:ip_blocks: read sensitive information of all IP blocks
admin:read:reports: read sensitive information of all reports and reported accounts admin:read:reports: read sensitive information of all reports and reported accounts
admin:write: modify all data on the server admin:write: modify all data on the server
admin:write:accounts: perform moderation actions on accounts admin:write:accounts: perform moderation actions on accounts
admin:write:canonical_email_blocks: perform moderation actions on canonical email blocks
admin:write:domain_allows: perform moderation actions on domain allows
admin:write:domain_blocks: perform moderation actions on domain blocks
admin:write:email_domain_blocks: perform moderation actions on email domain blocks
admin:write:ip_blocks: perform moderation actions on IP blocks
admin:write:reports: perform moderation actions on reports admin:write:reports: perform moderation actions on reports
crypto: use end-to-end encryption crypto: use end-to-end encryption
follow: modify account relationships follow: modify account relationships

@ -116,6 +116,8 @@ en:
redownloaded_msg: Successfully refreshed %{username}'s profile from origin redownloaded_msg: Successfully refreshed %{username}'s profile from origin
reject: Reject reject: Reject
rejected_msg: Successfully rejected %{username}'s sign-up application rejected_msg: Successfully rejected %{username}'s sign-up application
remote_suspension_irreversible: The data of this account has been irreversibly deleted.
remote_suspension_reversible_hint_html: The account has been suspended on their server, and the data will be fully removed on %{date}. Until then, the remote server can restore this account without any ill effects. If you wish to remove all of the account's data immediately, you can do so below.
remove_avatar: Remove avatar remove_avatar: Remove avatar
remove_header: Remove header remove_header: Remove header
removed_avatar_msg: Successfully removed %{username}'s avatar image removed_avatar_msg: Successfully removed %{username}'s avatar image
@ -555,13 +557,12 @@ en:
pending: Waiting for relay's approval pending: Waiting for relay's approval
save_and_enable: Save and enable save_and_enable: Save and enable
setup: Setup a relay connection setup: Setup a relay connection
signatures_not_enabled: Relays will not work correctly while secure mode or limited federation mode is enabled signatures_not_enabled: Relays may not work correctly while secure mode or limited federation mode is enabled
status: Status status: Status
title: Relays title: Relays
report_notes: report_notes:
created_msg: Report note successfully created! created_msg: Report note successfully created!
destroyed_msg: Report note successfully deleted! destroyed_msg: Report note successfully deleted!
today_at: Today at %{time}
reports: reports:
account: account:
notes: notes:
@ -974,6 +975,9 @@ en:
email_below_hint_html: If the below e-mail address is incorrect, you can change it here and receive a new confirmation e-mail. email_below_hint_html: If the below e-mail address is incorrect, you can change it here and receive a new confirmation e-mail.
email_settings_hint_html: The confirmation e-mail was sent to %{email}. If that e-mail address is not correct, you can change it in account settings. email_settings_hint_html: The confirmation e-mail was sent to %{email}. If that e-mail address is not correct, you can change it in account settings.
title: Setup title: Setup
sign_in:
preamble_html: Sign in with your <strong>%{domain}</strong> credentials. If your account is hosted on a different server, you will not be able to log in here.
title: Sign in to %{domain}
sign_up: sign_up:
preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted. preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted.
title: Let's get you set up on %{domain}. title: Let's get you set up on %{domain}.

@ -1,5 +1,5 @@
--- ---
:concurrency: 5 :concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 5) %>
:queues: :queues:
- [default, 8] - [default, 8]
- [push, 6] - [push, 6]

@ -34,6 +34,12 @@ module.exports = merge(sharedConfig, {
cache: true, cache: true,
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/, test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
}), }),
new CompressionPlugin({
filename: '[path][base].br[query]',
algorithm: 'brotliCompress',
cache: true,
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
}),
new BundleAnalyzerPlugin({ // generates report.html new BundleAnalyzerPlugin({ // generates report.html
analyzerMode: 'static', analyzerMode: 'static',
openAnalyzer: false, openAnalyzer: false,

@ -200,21 +200,44 @@ module Mastodon
end end
end end
desc 'delete USERNAME', 'Delete a user' option :email
option :dry_run, type: :boolean
desc 'delete [USERNAME]', 'Delete a user'
long_desc <<-LONG_DESC long_desc <<-LONG_DESC
Remove a user account with a given USERNAME. Remove a user account with a given USERNAME.
LONG_DESC
def delete(username)
account = Account.find_local(username)
if account.nil? With the --email option, the user is selected based on email
say('No user with such username', :red) rather than username.
LONG_DESC
def delete(username = nil)
if username.present? && options[:email].present?
say('Use username or --email, not both', :red)
exit(1)
elsif username.blank? && options[:email].blank?
say('No username provided', :red)
exit(1) exit(1)
end end
say("Deleting user with #{account.statuses_count} statuses, this might take a while...") dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
DeleteAccountService.new.call(account, reserve_email: false) account = nil
say('OK', :green)
if username.present?
account = Account.find_local(username)
if account.nil?
say('No user with such username', :red)
exit(1)
end
else
account = Account.left_joins(:user).find_by(user: { email: options[:email] })
if account.nil?
say('No user with such email', :red)
exit(1)
end
end
say("Deleting user with #{account.statuses_count} statuses, this might take a while...#{dry_run}")
DeleteAccountService.new.call(account, reserve_email: false) unless options[:dry_run]
say("OK#{dry_run}", :green)
end end
option :force, type: :boolean, aliases: [:f], description: 'Override public key check' option :force, type: :boolean, aliases: [:f], description: 'Override public key check'

@ -14,35 +14,78 @@ module Mastodon
end end
option :days, type: :numeric, default: 7, aliases: [:d] option :days, type: :numeric, default: 7, aliases: [:d]
option :prune_profiles, type: :boolean, default: false
option :remove_headers, type: :boolean, default: false
option :include_follows, type: :boolean, default: false
option :concurrency, type: :numeric, default: 5, aliases: [:c] option :concurrency, type: :numeric, default: 5, aliases: [:c]
option :verbose, type: :boolean, default: false, aliases: [:v]
option :dry_run, type: :boolean, default: false option :dry_run, type: :boolean, default: false
desc 'remove', 'Remove remote media files' desc 'remove', 'Remove remote media files, headers or avatars'
long_desc <<-DESC long_desc <<-DESC
Removes locally cached copies of media attachments from other servers. Removes locally cached copies of media attachments (and optionally profile
headers and avatars) from other servers. By default, only media attachements
are removed.
The --days option specifies how old media attachments have to be before The --days option specifies how old media attachments have to be before
they are removed. It defaults to 7 days. they are removed. In case of avatars and headers, it specifies how old
the last webfinger request and update to the user has to be before they
are pruned. It defaults to 7 days.
If --prune-profiles is specified, only avatars and headers are removed.
If --remove-headers is specified, only headers are removed.
If --include-follows is specified along with --prune-profiles or
--remove-headers, all non-local profiles will be pruned irrespective of
follow status. By default, only accounts that are not followed by or
following anyone locally are pruned.
DESC DESC
# rubocop:disable Metrics/PerceivedComplexity
def remove def remove
time_ago = options[:days].days.ago if options[:prune_profiles] && options[:remove_headers]
dry_run = options[:dry_run] ? '(DRY RUN)' : '' say('--prune-profiles and --remove-headers should not be specified simultaneously', :red, true)
exit(1)
end
if options[:include_follows] && !(options[:prune_profiles] || options[:remove_headers])
say('--include-follows can only be used with --prune-profiles or --remove-headers', :red, true)
exit(1)
end
time_ago = options[:days].days.ago
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where('created_at < ?', time_ago)) do |media_attachment| if options[:prune_profiles] || options[:remove_headers]
next if media_attachment.file.blank? processed, aggregate = parallelize_with_progress(Account.remote.where({ last_webfingered_at: ..time_ago, updated_at: ..time_ago })) do |account|
next if !options[:include_follows] && Follow.where(account: account).or(Follow.where(target_account: account)).exists?
next if account.avatar.blank? && account.header.blank?
next if options[:remove_headers] && account.header.blank?
size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0) size = (account.header_file_size || 0)
size += (account.avatar_file_size || 0) if options[:prune_profiles]
unless options[:dry_run] unless options[:dry_run]
media_attachment.file.destroy account.header.destroy
media_attachment.thumbnail.destroy account.avatar.destroy if options[:prune_profiles]
media_attachment.save account.save!
end
size
end end
size say("Visited #{processed} accounts and removed profile media totaling #{number_to_human_size(aggregate)}#{dry_run}", :green, true)
end end
say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)}) #{dry_run}", :green, true) unless options[:prune_profiles] || options[:remove_headers]
processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where(created_at: ..time_ago)) do |media_attachment|
next if media_attachment.file.blank?
size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
unless options[:dry_run]
media_attachment.file.destroy
media_attachment.thumbnail.destroy
media_attachment.save
end
size
end
say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true)
end
end end
option :start_after option :start_after
@ -183,6 +226,7 @@ module Mastodon
say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run}", :green, true) say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run}", :green, true)
end end
# rubocop:enable Metrics/PerceivedComplexity
option :account, type: :string option :account, type: :string
option :domain, type: :string option :domain, type: :string
@ -269,7 +313,7 @@ module Mastodon
def lookup(url) def lookup(url)
path = Addressable::URI.parse(url).path path = Addressable::URI.parse(url).path
path_segments = path.split('/')[2..-1] path_segments = path.split('/')[2..]
path_segments.delete('cache') path_segments.delete('cache')
unless [7, 10].include?(path_segments.size) unless [7, 10].include?(path_segments.size)

@ -194,7 +194,7 @@ namespace :mastodon do
env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q| env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q|
q.required true q.required true
q.default 's3-us-east-1.amazonaws.com' q.default 's3.us-east-1.amazonaws.com'
q.modify :strip q.modify :strip
end end

@ -2,7 +2,7 @@
"name": "@mastodon/mastodon", "name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": { "engines": {
"node": ">=14" "node": ">=16"
}, },
"scripts": { "scripts": {
"postversion": "git push --tags", "postversion": "git push --tags",

@ -1,24 +1,28 @@
// @ts-check // @ts-check
(function() { (function () {
'use strict'; 'use strict';
/** /**
* @param {() => void} loaded * @param {() => void} loaded
*/ */
var ready = function(loaded) { var ready = function (loaded) {
if (['interactive', 'complete'].indexOf(document.readyState) !== -1) { if (document.readyState === 'complete') {
loaded(); loaded();
} else { } else {
document.addEventListener('DOMContentLoaded', loaded); document.addEventListener('readystatechange', function () {
if (document.readyState === 'complete') {
loaded();
}
});
} }
}; };
ready(function() { ready(function () {
/** @type {Map<number, HTMLIFrameElement>} */ /** @type {Map<number, HTMLIFrameElement>} */
var iframes = new Map(); var iframes = new Map();
window.addEventListener('message', function(e) { window.addEventListener('message', function (e) {
var data = e.data || {}; var data = e.data || {};
if (typeof data !== 'object' || data.type !== 'setHeight' || !iframes.has(data.id)) { if (typeof data !== 'object' || data.type !== 'setHeight' || !iframes.has(data.id)) {
@ -34,7 +38,7 @@
iframe.height = data.height; iframe.height = data.height;
}); });
[].forEach.call(document.querySelectorAll('iframe.mastodon-embed'), function(iframe) { [].forEach.call(document.querySelectorAll('iframe.mastodon-embed'), function (iframe) {
// select unique id for each iframe // select unique id for each iframe
var id = 0, failCount = 0, idBuffer = new Uint32Array(1); var id = 0, failCount = 0, idBuffer = new Uint32Array(1);
while (id === 0 || iframes.has(id)) { while (id === 0 || iframes.has(id)) {
@ -49,10 +53,10 @@
iframes.set(id, iframe); iframes.set(id, iframe);
iframe.scrolling = 'no'; iframe.scrolling = 'no';
iframe.style.overflow = 'hidden'; iframe.style.overflow = 'hidden';
iframe.onload = function() { iframe.onload = function () {
iframe.contentWindow.postMessage({ iframe.contentWindow.postMessage({
type: 'setHeight', type: 'setHeight',
id: id, id: id,

@ -70,6 +70,53 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
end end
end end
describe 'PUT #update' do
let!(:remote_account) { Fabricate(:account, domain: 'example.com') }
let(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) }
before do
BlockDomainService.new.call(domain_block)
end
let(:subject) do
post :update, params: { id: domain_block.id, domain_block: { domain: 'example.com', severity: new_severity } }
end
context 'downgrading a domain suspension to silence' do
let(:original_severity) { 'suspend' }
let(:new_severity) { 'silence' }
it 'changes the block severity' do
expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence')
end
it 'undoes individual suspensions' do
expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false)
end
it 'performs individual silences' do
expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true)
end
end
context 'upgrading a domain silence to suspend' do
let(:original_severity) { 'silence' }
let(:new_severity) { 'suspend' }
it 'changes the block severity' do
expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
end
it 'undoes individual silences' do
expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false)
end
it 'performs individual suspends' do
expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true)
end
end
end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
it 'unblocks the domain' do it 'unblocks the domain' do
service = double(call: true) service = double(call: true)

@ -71,6 +71,53 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
end end
end end
describe 'PUT #update' do
let!(:remote_account) { Fabricate(:account, domain: 'example.com') }
let(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) }
before do
BlockDomainService.new.call(domain_block)
end
let(:subject) do
post :update, params: { id: domain_block.id, domain: 'example.com', severity: new_severity }
end
context 'downgrading a domain suspension to silence' do
let(:original_severity) { 'suspend' }
let(:new_severity) { 'silence' }
it 'changes the block severity' do
expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence')
end
it 'undoes individual suspensions' do
expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false)
end
it 'performs individual silences' do
expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true)
end
end
context 'upgrading a domain silence to suspend' do
let(:original_severity) { 'silence' }
let(:new_severity) { 'suspend' }
it 'changes the block severity' do
expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
end
it 'undoes individual silences' do
expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false)
end
it 'performs individual suspends' do
expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true)
end
end
end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
let!(:block) { Fabricate(:domain_block) } let!(:block) { Fabricate(:domain_block) }

@ -35,4 +35,65 @@ describe Auth::PasswordsController, type: :controller do
end end
end end
end end
describe 'POST #update' do
let(:user) { Fabricate(:user) }
before do
@password = 'reset0password'
request.env['devise.mapping'] = Devise.mappings[:user]
end
context 'with valid reset_password_token' do
let!(:session_activation) { Fabricate(:session_activation, user: user) }
let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) }
let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) }
before do
@token = user.send_reset_password_instructions
post :update, params: { user: { password: @password, password_confirmation: @password, reset_password_token: @token } }
end
it 'redirect to sign in' do
expect(response).to redirect_to '/auth/sign_in'
end
it 'changes password' do
this_user = User.find(user.id)
expect(this_user).to_not be_nil
expect(this_user.valid_password?(@password)).to be true
end
it 'deactivates all sessions' do
expect(user.session_activations.count).to eq 0
end
it 'revokes all access tokens' do
expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0
end
it 'removes push subscriptions' do
expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0
end
end
context 'with invalid reset_password_token' do
before do
post :update, params: { user: { password: @password, password_confirmation: @password, reset_password_token: 'some_invalid_value' } }
end
it 'renders reset password' do
expect(response).to render_template(:new)
end
it 'retains password' do
this_user = User.find(user.id)
expect(this_user).to_not be_nil
expect(this_user.external_or_valid_password?(user.password)).to be true
end
end
end
end end

@ -27,6 +27,8 @@ describe WellKnown::NodeInfoController, type: :controller do
json = body_as_json json = body_as_json
expect({ "foo" => 0 }).not_to match_json_schema("nodeinfo_2.0")
expect(json).to match_json_schema("nodeinfo_2.0")
expect(json[:version]).to eq '2.0' expect(json[:version]).to eq '2.0'
expect(json[:usage]).to be_a Hash expect(json[:usage]).to be_a Hash
expect(json[:software]).to be_a Hash expect(json[:software]).to be_a Hash

@ -113,7 +113,7 @@ describe ApplicationHelper do
Setting.site_title = site_title Setting.site_title = site_title
end end
it 'returns site title on production enviroment' do it 'returns site title on production environment' do
Setting.site_title = 'site title' Setting.site_title = 'site title'
expect(Rails.env).to receive(:production?).and_return(true) expect(Rails.env).to receive(:production?).and_return(true)
expect(helper.title).to eq 'site title' expect(helper.title).to eq 'site title'

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
describe FormattingHelper, type: :helper do
include Devise::Test::ControllerHelpers
describe '#rss_status_content_format' do
let(:status) { Fabricate(:status, text: 'Hello world<>', spoiler_text: 'This is a spoiler<>', poll: Fabricate(:poll, options: %w(Yes<> No))) }
let(:html) { helper.rss_status_content_format(status) }
it 'renders the spoiler text' do
expect(html).to include('<p>This is a spoiler&lt;&gt;</p><hr>')
end
it 'renders the status text' do
expect(html).to include('<p>Hello world&lt;&gt;</p>')
end
it 'renders the poll' do
expect(html).to include('<radio disabled="disabled">Yes&lt;&gt;</radio><br>')
end
end
end

@ -160,7 +160,7 @@ RSpec.describe Account, type: :model do
expect(account.avatar_remote_url).to eq 'https://remote.test/invalid_avatar' expect(account.avatar_remote_url).to eq 'https://remote.test/invalid_avatar'
expect(account.header_remote_url).to eq expectation.header_remote_url expect(account.header_remote_url).to eq expectation.header_remote_url
expect(account.avatar_file_name).to eq nil expect(account.avatar_file_name).to eq nil
expect(account.header_file_name).to eq nil expect(account.header_file_name).to eq expectation.header_file_name
end end
end end
end end
@ -658,6 +658,12 @@ RSpec.describe Account, type: :model do
end end
end end
describe '.requested_by_map' do
it 'returns an hash' do
expect(Account.requested_by_map([], 1)).to be_a Hash
end
end
describe 'MENTION_RE' do describe 'MENTION_RE' do
subject { Account::MENTION_RE } subject { Account::MENTION_RE }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save