Browse Source

Merge branch 'mastodon_4_5_5' into mastodon_4_3_0

pull/1371/head
Misty De Meo 2 months ago
parent
commit
b644d0077a
No known key found for this signature in database
GPG Key ID: 76CF846A2F674B2C
  1. 27
      CHANGELOG.md
  2. 19
      FEDERATION.md
  3. 3
      SECURITY.md
  4. 5
      app/controllers/activitypub/inboxes_controller.rb
  5. 11
      app/controllers/api/v1/statuses_controller.rb
  6. 2
      app/controllers/api/web/push_subscriptions_controller.rb
  7. 2
      app/controllers/concerns/cache_concern.rb
  8. 1
      app/javascript/mastodon/features/emoji/normalize.test.ts
  9. 6
      app/javascript/mastodon/features/emoji/normalize.ts
  10. 1
      app/javascript/styles/mastodon/admin.scss
  11. 8
      app/lib/activitypub/activity.rb
  12. 2
      app/lib/activitypub/activity/accept.rb
  13. 2
      app/lib/activitypub/activity/delete.rb
  14. 2
      app/lib/activitypub/activity/quote_request.rb
  15. 3
      app/lib/activitypub/activity/update.rb
  16. 6
      app/lib/activitypub/parser/poll_parser.rb
  17. 2
      app/lib/activitypub/tag_manager.rb
  18. 13
      app/lib/connection_pool/shared_connection_pool.rb
  19. 1
      app/lib/feed_manager.rb
  20. 6
      app/lib/signature_parser.rb
  21. 9
      app/models/account.rb
  22. 5
      app/models/custom_emoji.rb
  23. 3
      app/models/custom_filter.rb
  24. 4
      app/models/custom_filter_keyword.rb
  25. 3
      app/models/list.rb
  26. 4
      app/models/quote.rb
  27. 1
      app/services/activitypub/fetch_remote_status_service.rb
  28. 14
      app/services/activitypub/process_account_service.rb
  29. 2
      app/services/batched_remove_status_service.rb
  30. 2
      app/services/fan_out_on_write_service.rb
  31. 6
      docker-compose.yml
  32. 2
      lib/mastodon/version.rb
  33. 22
      spec/lib/activitypub/tag_manager_spec.rb
  34. 9
      spec/requests/api/v1/statuses_spec.rb
  35. 5
      spec/requests/api/web/push_subscriptions_spec.rb

27
CHANGELOG.md

@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.5.5] - 2026-01-20
### Security
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
### Changed
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
### Fixed
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable)
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec)
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros)
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima)
- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion)
## [4.5.4] - 2026-01-07 ## [4.5.4] - 2026-01-07
### Security ### Security

19
FEDERATION.md

@ -52,3 +52,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
### Additional documentation ### Additional documentation
- [Mastodon documentation](https://docs.joinmastodon.org/) - [Mastodon documentation](https://docs.joinmastodon.org/)
## Size limits
Mastodon imposes a few hard limits on federated content.
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
The following table attempts to summary those limits.
| Limited property | Size limit | Consequence of exceeding the limit |
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
| Account `attributionDomains` | 256 | List will be truncated |
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |

3
SECURITY.md

@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| 4.5.x | Yes | | 4.5.x | Yes |
| 4.4.x | Yes | | 4.4.x | Yes |
| 4.3.x | Until 2026-05-06 | | 4.3.x | Until 2026-05-06 |
| 4.2.x | Until 2026-01-08 | | < 4.3 | No |
| < 4.2 | No |

5
app/controllers/activitypub/inboxes_controller.rb

@ -3,6 +3,7 @@
class ActivityPub::InboxesController < ActivityPub::BaseController class ActivityPub::InboxesController < ActivityPub::BaseController
include JsonLdHelper include JsonLdHelper
before_action :skip_large_payload
before_action :skip_unknown_actor_activity before_action :skip_unknown_actor_activity
before_action :require_actor_signature! before_action :require_actor_signature!
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
private private
def skip_large_payload
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
end
def skip_unknown_actor_activity def skip_unknown_actor_activity
head 202 if unknown_affected_account? head 202 if unknown_affected_account?
end end

11
app/controllers/api/v1/statuses_controller.rb

@ -107,9 +107,7 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.where(account: current_account).find(params[:id]) @status = Status.where(account: current_account).find(params[:id])
authorize @status, :update? authorize @status, :update?
UpdateStatusService.new.call( update_options = {
@status,
current_account.id,
text: status_params[:status], text: status_params[:status],
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
media_attributes: status_params[:media_attributes], media_attributes: status_params[:media_attributes],
@ -117,8 +115,11 @@ class Api::V1::StatusesController < Api::BaseController
language: status_params[:language], language: status_params[:language],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
poll: status_params[:poll], poll: status_params[:poll],
quote_approval_policy: quote_approval_policy }
)
update_options[:quote_approval_policy] = quote_approval_policy if status_params[:quote_approval_policy].present?
UpdateStatusService.new.call(@status, current_account.id, update_options)
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
end end

2
app/controllers/api/web/push_subscriptions_controller.rb

@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end end
def set_push_subscription def set_push_subscription
@push_subscription = ::Web::PushSubscription.find(params[:id]) @push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
end end
def subscription_params def subscription_params

2
app/controllers/concerns/cache_concern.rb

@ -19,7 +19,7 @@ module CacheConcern
# from being used as cache keys, while allowing to `Vary` on them (to not serve # from being used as cache keys, while allowing to `Vary` on them (to not serve
# anonymous cached data to authenticated requests when authentication matters) # anonymous cached data to authenticated requests when authentication matters)
def enforce_cache_control! def enforce_cache_control!
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase } vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? } return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)

1
app/javascript/mastodon/features/emoji/normalize.test.ts

@ -33,6 +33,7 @@ describe('emojiToUnicodeHex', () => {
['⚫', '26AB'], ['⚫', '26AB'],
['🖤', '1F5A4'], ['🖤', '1F5A4'],
['💀', '1F480'], ['💀', '1F480'],
['❤', '2764'], // Checks for trailing variation selector removal.
['💂', '1F482-200D-2642-FE0F'], ['💂', '1F482-200D-2642-FE0F'],
] as const)( ] as const)(
'emojiToUnicodeHex converts %s to %s', 'emojiToUnicodeHex converts %s to %s',

6
app/javascript/mastodon/features/emoji/normalize.ts

@ -30,6 +30,12 @@ export function emojiToUnicodeHex(emoji: string): string {
codes.push(code); codes.push(code);
} }
} }
// Handles how Emojibase removes the variation selector for single code emojis.
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) {
codes.pop();
}
return hexNumbersToString(codes); return hexNumbersToString(codes);
} }

1
app/javascript/styles/mastodon/admin.scss

@ -170,6 +170,7 @@ $content-width: 840px;
width: 100%; width: 100%;
max-width: $content-width; max-width: $content-width;
flex: 1 1 auto; flex: 1 1 auto;
isolation: isolate;
} }
@media screen and (max-width: ($content-width + $sidebar-width)) { @media screen and (max-width: ($content-width + $sidebar-width)) {

8
app/lib/activitypub/activity.rb

@ -5,6 +5,7 @@ class ActivityPub::Activity
include Redisable include Redisable
include Lockable include Lockable
MAX_JSON_SIZE = 1.megabyte
SUPPORTED_TYPES = %w(Note Question Article).freeze SUPPORTED_TYPES = %w(Note Question Article).freeze
CONVERTED_TYPES = %w(Image Audio Video Page Event).freeze CONVERTED_TYPES = %w(Image Audio Video Page Event).freeze
@ -21,14 +22,13 @@ class ActivityPub::Activity
class << self class << self
def factory(json, account, **) def factory(json, account, **)
@json = json klass_for(json)&.new(json, account, **)
klass&.new(json, account, **)
end end
private private
def klass def klass_for(json)
case @json['type'] case json['type']
when 'Create' when 'Create'
ActivityPub::Activity::Create ActivityPub::Activity::Create
when 'Announce' when 'Announce'

2
app/lib/activitypub/activity/accept.rb

@ -46,7 +46,7 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
def accept_quote!(quote) def accept_quote!(quote)
approval_uri = value_or_id(first_of_value(@json['result'])) approval_uri = value_or_id(first_of_value(@json['result']))
return if unsupported_uri_scheme?(approval_uri) || quote.quoted_account != @account || !quote.status.local? return if unsupported_uri_scheme?(approval_uri) || quote.quoted_account != @account || !quote.status.local? || !quote.pending?
# NOTE: we are not going through `ActivityPub::VerifyQuoteService` as the `Accept` is as authoritative # NOTE: we are not going through `ActivityPub::VerifyQuoteService` as the `Accept` is as authoritative
# as the stamp, but this means we are not checking the stamp, which may lead to inconsistencies # as the stamp, but this means we are not checking the stamp, which may lead to inconsistencies

2
app/lib/activitypub/activity/delete.rb

@ -56,7 +56,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end end
def revoke_quote def revoke_quote
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account) @quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account, state: [:pending, :accepted])
return if @quote.nil? return if @quote.nil?
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward! if @quote.status.present? ActivityPub::Forwarder.new(@account, @json, @quote.status).forward! if @quote.status.present?

2
app/lib/activitypub/activity/quote_request.rb

@ -47,7 +47,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
# NOTE: Replacing the object's context by that of the parent activity is # NOTE: Replacing the object's context by that of the parent activity is
# not sound, but it's consistent with the rest of the codebase # not sound, but it's consistent with the rest of the codebase
instrument = @json['instrument'].merge({ '@context' => @json['@context'] }) instrument = @json['instrument'].merge({ '@context' => @json['@context'] })
return if non_matching_uri_hosts?(instrument['id'], @account.uri) return if non_matching_uri_hosts?(@account.uri, instrument['id'])
ActivityPub::FetchRemoteStatusService.new.call(instrument['id'], prefetched_body: instrument, on_behalf_of: quoted_status.account, request_id: @options[:request_id]) ActivityPub::FetchRemoteStatusService.new.call(instrument['id'], prefetched_body: instrument, on_behalf_of: quoted_status.account, request_id: @options[:request_id])
end end

3
app/lib/activitypub/activity/update.rb

@ -30,7 +30,8 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
@status = Status.find_by(uri: object_uri, account_id: @account.id) @status = Status.find_by(uri: object_uri, account_id: @account.id)
# Ignore updates for old unknown objects, since those are updates we are not interested in # Ignore updates for old unknown objects, since those are updates we are not interested in
return if @status.nil? && object_too_old? # Also ignore unknown objects from suspended users for the same reasons
return if @status.nil? && (@account.suspended? || object_too_old?)
# We may be getting `Create` and `Update` out of order # We may be getting `Create` and `Update` out of order
@status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform @status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform

6
app/lib/activitypub/parser/poll_parser.rb

@ -3,6 +3,10 @@
class ActivityPub::Parser::PollParser class ActivityPub::Parser::PollParser
include JsonLdHelper include JsonLdHelper
# Limit the number of items for performance purposes.
# We truncate rather than error out to avoid missing the post entirely.
MAX_ITEMS = 500
def initialize(json) def initialize(json)
@json = json @json = json
end end
@ -48,6 +52,6 @@ class ActivityPub::Parser::PollParser
private private
def items def items
@json['anyOf'] || @json['oneOf'] (@json['anyOf'] || @json['oneOf'])&.take(MAX_ITEMS)
end end
end end

2
app/lib/activitypub/tag_manager.rb

@ -50,7 +50,7 @@ class ActivityPub::TagManager
context_url(target) unless target.parent_account_id.nil? || target.parent_status_id.nil? context_url(target) unless target.parent_account_id.nil? || target.parent_status_id.nil?
when :note, :comment, :activity when :note, :comment, :activity
if target.account.numeric_ap_id? if target.account.numeric_ap_id?
return activity_ap_account_status_url(target.account, target) if target.reblog? return activity_ap_account_status_url(target.account.id, target) if target.reblog?
ap_account_status_url(target.account.id, target) ap_account_status_url(target.account.id, target)
else else

13
app/lib/connection_pool/shared_connection_pool.rb

@ -41,12 +41,17 @@ class ConnectionPool::SharedConnectionPool < ConnectionPool
# ConnectionPool 2.4+ calls `checkin(force: true)` after fork. # ConnectionPool 2.4+ calls `checkin(force: true)` after fork.
# When this happens, we should remove all connections from Thread.current # When this happens, we should remove all connections from Thread.current
::Thread.current.keys.each do |name| # rubocop:disable Style/HashEachMethods connection_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key}-") && !key.to_s.start_with?("#{@key_count}-") }
next unless name.to_s.start_with?("#{@key}-") count_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key_count}-") }
@available.push(::Thread.current[name]) connection_keys.each do |key|
::Thread.current[name] = nil @available.push(::Thread.current[key])
::Thread.current[key] = nil
end end
count_keys.each do |key|
::Thread.current[key] = nil
end
elsif ::Thread.current[key(preferred_tag)] elsif ::Thread.current[key(preferred_tag)]
if ::Thread.current[key_count(preferred_tag)] == 1 if ::Thread.current[key_count(preferred_tag)] == 1
@available.push(::Thread.current[key(preferred_tag)]) @available.push(::Thread.current[key(preferred_tag)])

1
app/lib/feed_manager.rb

@ -450,6 +450,7 @@ class FeedManager
return :filter if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) return :filter if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
return :skip_home if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present? return :skip_home if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present?
return :filter if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language) return :filter if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
return :filter if status.reblog? && status.reblog.blank?
check_for_blocks = crutches[:active_mentions][status.id] || [] check_for_blocks = crutches[:active_mentions][status.id] || []
check_for_blocks.push(status.account_id) check_for_blocks.push(status.account_id)

6
app/lib/signature_parser.rb

@ -25,9 +25,13 @@ class SignatureParser
# Use `skip` instead of `scan` as we only care about the subgroups # Use `skip` instead of `scan` as we only care about the subgroups
while scanner.skip(PARAM_RE) while scanner.skip(PARAM_RE)
key = scanner[:key]
# Detect a duplicate key
raise Mastodon::SignatureVerificationError, 'Error parsing signature with duplicate keys' if params.key?(key)
# This is not actually correct with regards to quoted pairs, but it's consistent # This is not actually correct with regards to quoted pairs, but it's consistent
# with our previous implementation, and good enough in practice. # with our previous implementation, and good enough in practice.
params[scanner[:key]] = scanner[:value] || scanner[:quoted_value][1...-1] params[key] = scanner[:value] || scanner[:quoted_value][1...-1]
scanner.skip(/\s*/) scanner.skip(/\s*/)
return params if scanner.eos? return params if scanner.eos?

9
app/models/account.rb

@ -80,6 +80,13 @@ class Account < ApplicationRecord
DISPLAY_NAME_LENGTH_LIMIT = 30 DISPLAY_NAME_LENGTH_LIMIT = 30
NOTE_LENGTH_LIMIT = 500 NOTE_LENGTH_LIMIT = 500
# Hard limits for federated content
USERNAME_LENGTH_HARD_LIMIT = 2048
DISPLAY_NAME_LENGTH_HARD_LIMIT = 2048
NOTE_LENGTH_HARD_LIMIT = 20.kilobytes
ATTRIBUTION_DOMAINS_HARD_LIMIT = 256
ALSO_KNOWN_AS_HARD_LIMIT = 256
AUTOMATED_ACTOR_TYPES = %w(Application Service).freeze AUTOMATED_ACTOR_TYPES = %w(Application Service).freeze
include Attachmentable # Load prior to Avatar & Header concerns include Attachmentable # Load prior to Avatar & Header concerns
@ -112,7 +119,7 @@ class Account < ApplicationRecord
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? } validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
# Remote user validations, also applies to internal actors # Remote user validations, also applies to internal actors
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? } validates :username, format: { with: USERNAME_ONLY_RE }, length: { maximum: USERNAME_LENGTH_HARD_LIMIT }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? }
# Remote user validations # Remote user validations
validates :uri, presence: true, unless: :local?, on: :create validates :uri, presence: true, unless: :local?, on: :create

5
app/models/custom_emoji.rb

@ -26,6 +26,8 @@ class CustomEmoji < ApplicationRecord
LIMIT = 256.kilobytes LIMIT = 256.kilobytes
MINIMUM_SHORTCODE_SIZE = 2 MINIMUM_SHORTCODE_SIZE = 2
MAX_SHORTCODE_SIZE = 128
MAX_FEDERATED_SHORTCODE_SIZE = 2048
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
@ -45,7 +47,8 @@ class CustomEmoji < ApplicationRecord
normalizes :domain, with: ->(domain) { domain.downcase.strip } normalizes :domain, with: ->(domain) { domain.downcase.strip }
validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT } validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE } validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE, maximum: MAX_FEDERATED_SHORTCODE_SIZE }
validates :shortcode, length: { maximum: MAX_SHORTCODE_SIZE }, if: :local?
scope :local, -> { where(domain: nil) } scope :local, -> { where(domain: nil) }
scope :remote, -> { where.not(domain: nil) } scope :remote, -> { where.not(domain: nil) }

3
app/models/custom_filter.rb

@ -30,6 +30,8 @@ class CustomFilter < ApplicationRecord
EXPIRATION_DURATIONS = [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].freeze EXPIRATION_DURATIONS = [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].freeze
TITLE_LENGTH_LIMIT = 256
include Expireable include Expireable
include Redisable include Redisable
@ -41,6 +43,7 @@ class CustomFilter < ApplicationRecord
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
validates :title, :context, presence: true validates :title, :context, presence: true
validates :title, length: { maximum: TITLE_LENGTH_LIMIT }
validate :context_must_be_valid validate :context_must_be_valid
normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) } normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) }

4
app/models/custom_filter_keyword.rb

@ -17,7 +17,9 @@ class CustomFilterKeyword < ApplicationRecord
belongs_to :custom_filter belongs_to :custom_filter
validates :keyword, presence: true KEYWORD_LENGTH_LIMIT = 512
validates :keyword, presence: true, length: { maximum: KEYWORD_LENGTH_LIMIT }
alias_attribute :phrase, :keyword alias_attribute :phrase, :keyword

3
app/models/list.rb

@ -17,6 +17,7 @@ class List < ApplicationRecord
include Paginable include Paginable
PER_ACCOUNT_LIMIT = 50 PER_ACCOUNT_LIMIT = 50
TITLE_LENGTH_LIMIT = 256
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true
@ -26,7 +27,7 @@ class List < ApplicationRecord
has_many :accounts, through: :list_accounts has_many :accounts, through: :list_accounts
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account
validates :title, presence: true validates :title, presence: true, length: { maximum: TITLE_LENGTH_LIMIT }
validate :validate_account_lists_limit, on: :create validate :validate_account_lists_limit, on: :create

4
app/models/quote.rb

@ -51,9 +51,9 @@ class Quote < ApplicationRecord
def reject! def reject!
if accepted? if accepted?
update!(state: :revoked) update!(state: :revoked, approval_uri: nil)
elsif !revoked? elsif !revoked?
update!(state: :rejected) update!(state: :rejected, approval_uri: nil)
end end
end end

1
app/services/activitypub/fetch_remote_status_service.rb

@ -92,7 +92,6 @@ class ActivityPub::FetchRemoteStatusService < BaseService
existing_status = Status.remote.find_by(uri: uri) existing_status = Status.remote.find_by(uri: uri)
if existing_status&.distributable? if existing_status&.distributable?
Rails.logger.debug { "FetchRemoteStatusService - Got 404 for orphaned status with URI #{uri}, deleting" } Rails.logger.debug { "FetchRemoteStatusService - Got 404 for orphaned status with URI #{uri}, deleting" }
Tombstone.find_or_create_by(uri: uri, account: existing_status.account)
RemoveStatusService.new.call(existing_status, redraft: false) RemoveStatusService.new.call(existing_status, redraft: false)
end end
end end

14
app/services/activitypub/process_account_service.rb

@ -6,6 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService
include Redisable include Redisable
include Lockable include Lockable
MAX_PROFILE_FIELDS = 50
SUBDOMAINS_RATELIMIT = 10 SUBDOMAINS_RATELIMIT = 10
DISCOVERIES_PER_REQUEST = 400 DISCOVERIES_PER_REQUEST = 400
@ -123,15 +124,15 @@ class ActivityPub::ProcessAccountService < BaseService
def set_immediate_attributes! def set_immediate_attributes!
@account.featured_collection_url = valid_collection_uri(@json['featured']) @account.featured_collection_url = valid_collection_uri(@json['featured'])
@account.display_name = @json['name'] || '' @account.display_name = (@json['name'] || '')[0...(Account::DISPLAY_NAME_LENGTH_HARD_LIMIT)]
@account.note = @json['summary'] || '' @account.note = (@json['summary'] || '')[0...(Account::NOTE_LENGTH_HARD_LIMIT)]
@account.locked = @json['manuallyApprovesFollowers'] || false @account.locked = @json['manuallyApprovesFollowers'] || false
@account.fields = property_values || {} @account.fields = property_values || {}
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } @account.also_known_as = as_array(@json['alsoKnownAs'] || []).take(Account::ALSO_KNOWN_AS_HARD_LIMIT).map { |item| value_or_id(item) }
@account.discoverable = @json['discoverable'] || false @account.discoverable = @json['discoverable'] || false
@account.indexable = @json['indexable'] || false @account.indexable = @json['indexable'] || false
@account.memorial = @json['memorial'] || false @account.memorial = @json['memorial'] || false
@account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) } @account.attribution_domains = as_array(@json['attributionDomains'] || []).take(Account::ATTRIBUTION_DOMAINS_HARD_LIMIT).map { |item| value_or_id(item) }
end end
def set_fetchable_key! def set_fetchable_key!
@ -252,7 +253,10 @@ class ActivityPub::ProcessAccountService < BaseService
def property_values def property_values
return unless @json['attachment'].is_a?(Array) return unless @json['attachment'].is_a?(Array)
as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') } as_array(@json['attachment'])
.select { |attachment| attachment['type'] == 'PropertyValue' }
.take(MAX_PROFILE_FIELDS)
.map { |attachment| attachment.slice('name', 'value') }
end end
def mismatching_origin?(url) def mismatching_origin?(url)

2
app/services/batched_remove_status_service.rb

@ -31,7 +31,7 @@ class BatchedRemoveStatusService < BaseService
# transaction lock the database, but we use the delete method instead # transaction lock the database, but we use the delete method instead
# of destroy to avoid all callbacks. We rely on foreign keys to # of destroy to avoid all callbacks. We rely on foreign keys to
# cascade the delete faster without loading the associations. # cascade the delete faster without loading the associations.
statuses_and_reblogs.each_slice(50) { |slice| Status.where(id: slice.map(&:id)).delete_all } statuses_and_reblogs.each_slice(50) { |slice| Status.unscoped.where(id: slice.pluck(:id)).delete_all }
# Since we skipped all callbacks, we also need to manually # Since we skipped all callbacks, we also need to manually
# deindex the statuses # deindex the statuses

2
app/services/fan_out_on_write_service.rb

@ -14,6 +14,8 @@ class FanOutOnWriteService < BaseService
@account = status.account @account = status.account
@options = options @options = options
return if @status.proper.account.suspended?
check_race_condition! check_race_condition!
warm_payload_cache! warm_payload_cache!

6
docker-compose.yml

@ -59,7 +59,7 @@ services:
web: web:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: . # build: .
image: ghcr.io/mastodon/mastodon:v4.5.4 image: ghcr.io/mastodon/mastodon:v4.5.5
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec puma -C config/puma.rb command: bundle exec puma -C config/puma.rb
@ -83,7 +83,7 @@ services:
# build: # build:
# dockerfile: ./streaming/Dockerfile # dockerfile: ./streaming/Dockerfile
# context: . # context: .
image: ghcr.io/mastodon/mastodon-streaming:v4.5.4 image: ghcr.io/mastodon/mastodon-streaming:v4.5.5
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming/index.js command: node ./streaming/index.js
@ -102,7 +102,7 @@ services:
sidekiq: sidekiq:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: . # build: .
image: ghcr.io/mastodon/mastodon:v4.5.4 image: ghcr.io/mastodon/mastodon:v4.5.5
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

2
lib/mastodon/version.rb

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
4 5
end end
def default_prerelease def default_prerelease

22
spec/lib/activitypub/tag_manager_spec.rb

@ -128,6 +128,28 @@ RSpec.describe ActivityPub::TagManager do
.to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}") .to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}")
end end
end end
context 'with a reblog' do
let(:status) { Fabricate(:status, account:, reblog: Fabricate(:status)) }
context 'when using a numeric ID based scheme' do
let(:account) { Fabricate(:account, id_scheme: :numeric_ap_id) }
it 'returns a string starting with web domain and with the expected path' do
expect(subject.uri_for(status))
.to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}/activity")
end
end
context 'when using the legacy username based scheme' do
let(:account) { Fabricate(:account, id_scheme: :username_ap_id) }
it 'returns a string starting with web domain and with the expected path' do
expect(subject.uri_for(status))
.to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/activity")
end
end
end
end end
context 'with a remote status' do context 'with a remote status' do

9
spec/requests/api/v1/statuses_spec.rb

@ -508,6 +508,15 @@ RSpec.describe '/api/v1/statuses' do
.to start_with('application/json') .to start_with('application/json')
end end
end end
context 'when status has non-default quote policy and param is omitted' do
let(:status) { Fabricate(:status, account: user.account, quote_approval_policy: 'nobody') }
it 'preserves existing quote approval policy' do
expect { subject }
.to_not(change { status.reload.quote_approval_policy })
end
end
end end
end end

5
spec/requests/api/web/push_subscriptions_spec.rb

@ -163,9 +163,10 @@ RSpec.describe 'API Web Push Subscriptions' do
end end
describe 'PUT /api/web/push_subscriptions/:id' do describe 'PUT /api/web/push_subscriptions/:id' do
before { sign_in Fabricate :user } before { sign_in user }
let(:subscription) { Fabricate :web_push_subscription } let(:user) { Fabricate(:user) }
let(:subscription) { Fabricate(:web_push_subscription, user: user) }
it 'gracefully handles invalid nested params' do it 'gracefully handles invalid nested params' do
put api_web_push_subscription_path(subscription), params: { data: 'invalid' } put api_web_push_subscription_path(subscription), params: { data: 'invalid' }

Loading…
Cancel
Save