Merge tag 'v4.2.23'

This commit is contained in:
bgme 2025-07-23 22:47:38 +08:00
commit 8c8b6b6c6a
24 changed files with 252 additions and 52 deletions

View file

@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file.
## [4.2.23] - 2025-07-23
### Security
- Updated dependencies
## [4.2.22] - 2025-07-02
### Changed
- Change passthrough video processing to emit `moov` atom at start of video (#34726 by @ClearlyClaire)
### Fixed
- Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk)
- Fix error when viewing statuses to deleted replies in moderation view (#32986 by @ClearlyClaire)
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
- Fix handling of remote attachments with multiple media types (#34996 by @ClearlyClaire)
- Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire)
- Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire)
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
- Fix `/share` not using server-set characters limit (#33459 by @kescherCode)
- Fix wrong video dimensions for some rotated videos (#33008 and #33261 by @Gargron and @tribela)
## [4.2.21] - 2025-05-06
### Security

View file

@ -446,7 +446,7 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0808)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
mini_portile2 (2.8.9)
minitest (5.19.0)
msgpack (1.7.1)
multi_json (1.15.0)
@ -469,7 +469,7 @@ GEM
net-protocol
net-ssh (7.1.0)
nio4r (2.7.4)
nokogiri (1.18.8)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nsa (0.3.0)
@ -533,7 +533,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.13)
rack (2.2.17)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
@ -741,7 +741,7 @@ GEM
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
test-prof (1.2.3)
thor (1.3.2)
thor (1.4.0)
tilt (2.2.0)
timeout (0.4.3)
tpm-key_attestation (0.12.0)

View file

@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
## Supported Versions
| Version | Supported |
| ------- | --------- |
| 4.3.x | Yes |
| 4.2.x | Yes |
| < 4.2 | No |
| Version | Supported |
| ------- | ---------------- |
| 4.4.x | Yes |
| 4.3.x | Yes |
| 4.2.x | Until 2026-01-08 |
| < 4.2 | No |

View file

@ -26,6 +26,8 @@ module JsonLdHelper
# The url attribute can be a string, an array of strings, or an array of objects.
# The objects could include a mimeType. Not-included mimeType means it's text/html.
def url_to_href(value, preferred_type = nil)
value = [value] if value.is_a?(Hash)
single_value = if value.is_a?(Array) && !value.first.is_a?(String)
value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
elsif value.is_a?(Array)
@ -41,6 +43,15 @@ module JsonLdHelper
end
end
def url_to_media_type(value, preferred_type = nil)
value = [value] if value.is_a?(Hash)
return unless value.is_a?(Array) && !value.first.is_a?(String)
single_value = value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
single_value['mediaType'] unless single_value.nil?
end
def as_array(value)
if value.nil?
[]

View file

@ -3,18 +3,19 @@ import { PureComponent } from 'react';
import { Provider } from 'react-redux';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { fetchServer } from '../actions/server';
import { hydrateStore } from '../actions/store';
import Compose from '../features/standalone/compose';
import initialState from '../initial_state';
import { IntlProvider } from '../locales';
import { store } from '../store';
if (initialState) {
store.dispatch(hydrateStore(initialState));
}
store.dispatch(fetchCustomEmojis());
store.dispatch(fetchServer());
export default class ComposeContainer extends PureComponent {

View file

@ -15,7 +15,7 @@ class ActivityPub::Parser::MediaAttachmentParser
end
def remote_url
url = Addressable::URI.parse(@json['url'])&.normalize&.to_s
url = Addressable::URI.parse(url_to_href(@json['url']))&.normalize&.to_s
url unless unsupported_uri_scheme?(url)
rescue Addressable::URI::InvalidURIError
nil
@ -43,7 +43,7 @@ class ActivityPub::Parser::MediaAttachmentParser
end
def file_content_type
@json['mediaType']
@json['mediaType'] || url_to_media_type(@json['url'])
end
private

View file

@ -17,7 +17,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
return true unless Chewy.enabled?
running_version.present? && compatible_version? && cluster_health['status'] == 'green' && indexes_match? && preset_matches?
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error, HTTPClient::KeepAliveDisconnected
false
end
@ -49,7 +49,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
else
Admin::SystemCheck::Message.new(:elasticsearch_preset, nil, 'https://docs.joinmastodon.org/admin/elasticsearch/#scaling')
end
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error, HTTPClient::KeepAliveDisconnected
Admin::SystemCheck::Message.new(:elasticsearch_running_check)
end

View file

@ -26,7 +26,9 @@ class EntityCache
uncached_ids << shortcode unless cached.key?(to_key(:emoji, shortcode, domain))
end
unless uncached_ids.empty?
if uncached_ids.empty?
uncached = {}
else
uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).index_by(&:shortcode)
uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) }
end

View file

@ -35,7 +35,7 @@ class SearchQueryTransformer < Parslet::Transform
private
def clauses_by_operator
@clauses_by_operator ||= @clauses.compact.chunk(&:operator).to_h
@clauses_by_operator ||= @clauses.compact.group_by(&:operator)
end
def flags_from_clauses!

View file

@ -38,7 +38,7 @@ class StatusFilter
end
def silenced_account?
!account&.silenced? && status_account_silenced? && !account_following_status_account?
status_account_silenced? && !account_following_status_account?
end
def status_account_silenced?

View file

@ -46,6 +46,9 @@ class VideoMetadataExtractor
# For some video streams the frame_rate reported by `ffprobe` will be 0/0, but for these streams we
# should use `r_frame_rate` instead. Video screencast generated by Gnome Screencast have this issue.
@frame_rate ||= @r_frame_rate
# If the video has not been re-encoded by ffmpeg, it may contain rotation information,
# and we need to simulate applying it to the dimensions
@width, @height = @height, @width if video_stream[:side_data_list]&.any? { |x| x[:rotation]&.abs == 90 }
end
if (audio_stream = audio_streams.first)

View file

@ -69,6 +69,7 @@ class Account < ApplicationRecord
MENTION_RE = %r{(?<![=/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]]+([.-]+[[:word:]]+)*)?)}
URL_PREFIX_RE = %r{\Ahttp(s?)://[^/]+}
USERNAME_ONLY_RE = /\A#{USERNAME_RE}\z/i
DISPLAY_NAME_LENGTH_LIMIT = 30
include Attachmentable
include AccountAssociations
@ -99,7 +100,7 @@ class Account < ApplicationRecord
# Local user validations
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
validates :display_name, length: { maximum: DISPLAY_NAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_display_name? }
validates :note, note_length: { maximum: 500 }, if: -> { local? && will_save_change_to_note? }
validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
validates :uri, absence: true, if: :local?, on: :create

View file

@ -99,7 +99,7 @@ module Omniauthable
external: true,
account_attributes: {
username: ensure_unique_username(ensure_valid_username(auth.uid)),
display_name: auth.info.full_name || auth.info.name || [auth.info.first_name, auth.info.last_name].join(' '),
display_name: display_name_from_auth(auth),
},
}
end
@ -121,5 +121,10 @@ module Omniauthable
temp_username = starting_username.gsub(/[^a-z0-9_]+/i, '')
temp_username.truncate(30, omission: '')
end
def display_name_from_auth(auth)
display_name = auth.info.full_name || auth.info.name || [auth.info.first_name, auth.info.last_name].join(' ')
display_name.truncate(Account::DISPLAY_NAME_LENGTH_LIMIT, omission: '')
end
end
end

View file

@ -122,6 +122,7 @@ class MediaAttachment < ApplicationRecord
output: {
'loglevel' => 'fatal',
'map_metadata' => '-1',
'movflags' => 'faststart', # Move metadata to start of file so playback can begin before download finishes
'c:v' => 'copy',
'c:a' => 'copy',
}.freeze,

View file

@ -4,13 +4,12 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
include JsonLdHelper
def call(account, **options)
return if account.featured_collection_url.blank? || account.suspended? || account.local?
return if (account.featured_collection_url.blank? && options[:collection].blank?) || account.suspended? || account.local?
@account = account
@options = options
@json = fetch_resource(@account.featured_collection_url, true, local_follower)
return unless supported_context?(@json)
@json = fetch_collection(options[:collection].presence || @account.featured_collection_url)
return if @json.blank?
process_items(collection_items(@json))
end

View file

@ -57,7 +57,7 @@ class ActivityPub::ProcessAccountService < BaseService
after_suspension_change! if suspension_changed?
unless @options[:only_key] || @account.suspended?
check_featured_collection! if @account.featured_collection_url.present?
check_featured_collection! if @json['featured'].present?
check_featured_tags_collection! if @json['featuredTags'].present?
check_links! if @account.fields.any?(&:requires_verification?)
end
@ -121,7 +121,7 @@ class ActivityPub::ProcessAccountService < BaseService
end
def set_immediate_attributes!
@account.featured_collection_url = @json['featured'] || ''
@account.featured_collection_url = valid_collection_uri(@json['featured'])
@account.devices_url = @json['devices'] || ''
@account.display_name = @json['name'] || ''
@account.note = @json['summary'] || ''
@ -186,7 +186,7 @@ class ActivityPub::ProcessAccountService < BaseService
end
def check_featured_collection!
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id, { 'hashtag' => @json['featuredTags'].blank?, 'request_id' => @options[:request_id] })
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id, { 'hashtag' => @json['featuredTags'].blank?, 'collection' => @json['featured'], 'request_id' => @options[:request_id] })
end
def check_featured_tags_collection!

View file

@ -15,7 +15,7 @@
- if @status.reply?
%tr
%th= t('admin.statuses.in_reply_to')
%td= admin_account_link_to @status.in_reply_to_account, path: admin_account_status_path(@status.thread.account_id, @status.in_reply_to_id)
%td= admin_account_link_to @status.in_reply_to_account, path: @status.thread.present? ? admin_account_status_path(@status.thread.account_id, @status.in_reply_to_id) : nil
%tr
%th= t('admin.statuses.application')
%td= @status.application&.name

View file

@ -6,7 +6,7 @@ class ActivityPub::SynchronizeFeaturedCollectionWorker
sidekiq_options queue: 'pull', lock: :until_executed, lock_ttl: 1.day.to_i
def perform(account_id, options = {})
options = { note: true, hashtag: false }.deep_merge(options.deep_symbolize_keys)
options = { note: true, hashtag: false }.deep_merge(options.symbolize_keys)
ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id), **options)
rescue ActiveRecord::RecordNotFound

View file

@ -56,7 +56,7 @@ services:
web:
build: .
image: ghcr.io/mastodon/mastodon:v4.2.21
image: ghcr.io/mastodon/mastodon:v4.2.23
restart: always
env_file: .env.production
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
@ -77,7 +77,7 @@ services:
streaming:
build: .
image: ghcr.io/mastodon/mastodon:v4.2.21
image: ghcr.io/mastodon/mastodon:v4.2.23
restart: always
env_file: .env.production
command: node ./streaming
@ -95,7 +95,7 @@ services:
sidekiq:
build: .
image: ghcr.io/mastodon/mastodon:v4.2.21
image: ghcr.io/mastodon/mastodon:v4.2.23
restart: always
env_file: .env.production
command: bundle exec sidekiq

View file

@ -13,7 +13,7 @@ module Mastodon
end
def patch
21
23
end
def default_prerelease

View file

@ -4,29 +4,60 @@ require 'rails_helper'
RSpec.describe ActivityPub::Activity::Remove do
let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured') }
let(:status) { Fabricate(:status, account: sender) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Add',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(status),
target: sender.featured_collection_url,
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
before do
StatusPin.create!(account: sender, status: status)
subject.perform
context 'when removing a pinned status' do
let(:status) { Fabricate(:status, account: sender) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Remove',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(status),
target: sender.featured_collection_url,
}.deep_stringify_keys
end
before do
StatusPin.create!(account: sender, status: status)
end
it 'removes a pin' do
expect { subject.perform }
.to change { sender.pinned?(status) }.to(false)
end
end
it 'removes a pin' do
expect(sender.pinned?(status)).to be false
context 'when removing a featured tag' do
let(:tag) { Fabricate(:tag) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Remove',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
type: 'Hashtag',
name: "##{tag.display_name}",
href: "https://example.com/tags/#{tag.name}",
},
target: sender.featured_collection_url,
}.deep_stringify_keys
end
before do
sender.featured_tags.find_or_create_by!(name: tag.name)
end
it 'removes a pin' do
expect { subject.perform }
.to change { sender.featured_tags.exists?(tag: tag) }.to(false)
end
end
end
end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::Parser::MediaAttachmentParser do
subject { described_class.new(json) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
}.deep_stringify_keys
end
it 'correctly parses media attachment' do
expect(subject).to have_attributes(
remote_url: 'http://example.com/attachment.png',
file_content_type: 'image/png'
)
end
context 'when the URL is a link with multiple options' do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Document',
url: [
{
type: 'Link',
mediaType: 'image/png',
href: 'http://example.com/attachment.png',
},
{
type: 'Link',
mediaType: 'image/avif',
href: 'http://example.com/attachment.avif',
},
],
}.deep_stringify_keys
end
it 'returns the first option' do
expect(subject).to have_attributes(
remote_url: 'http://example.com/attachment.png',
file_content_type: 'image/png'
)
end
end
end

View file

@ -77,4 +77,24 @@ describe SearchQueryTransformer do
expect(subject.send(:filter_clauses).map(&:term)).to contain_exactly(lt: '2022-01-01 23:00', time_zone: 'UTC')
end
end
context 'with multiple prefix clauses before a search term' do
let(:query) { 'from:me has:media foo' }
it 'transforms clauses' do
expect(subject.send(:must_clauses).map(&:term)).to contain_exactly('foo')
expect(subject.send(:must_not_clauses)).to be_empty
expect(subject.send(:filter_clauses).map(&:prefix)).to contain_exactly('from', 'has')
end
end
context 'with a search term between two prefix clauses' do
let(:query) { 'from:me foo has:media' }
it 'transforms clauses' do
expect(subject.send(:must_clauses).map(&:term)).to contain_exactly('foo')
expect(subject.send(:must_not_clauses)).to be_empty
expect(subject.send(:filter_clauses).map(&:prefix)).to contain_exactly('from', 'has')
end
end
end

View file

@ -67,7 +67,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
type: 'Collection',
id: actor.featured_collection_url,
items: items,
}.with_indifferent_access
}.deep_stringify_keys
end
shared_examples 'sets pinned posts' do
@ -91,6 +91,55 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
end
describe '#call' do
subject { described_class.new.call(actor, note: true, hashtag: false) }
shared_examples 'sets pinned posts' do
before do
stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404)
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' })
stub_request(:get, 'https://example.com/account/collections/featured').to_return(status: 200, body: Oj.dump(featured_with_null), headers: { 'Content-Type': 'application/activity+json' })
subject
end
it 'sets expected posts as pinned posts' do
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
'https://example.com/account/pinned/known',
'https://example.com/account/pinned/unknown-inlined',
'https://example.com/account/pinned/unknown-reachable'
)
expect(actor.pinned_statuses).to_not include(known_status)
end
end
context 'when passing the collection via an argument' do
subject { described_class.new.call(actor, note: true, hashtag: false, collection: collection_or_uri) }
context 'when the collection is an URL' do
let(:collection_or_uri) { actor.featured_collection_url }
before do
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end
it_behaves_like 'sets pinned posts'
end
context 'when the collection is inlined' do
let(:collection_or_uri) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
items: items,
}.deep_stringify_keys
end
it_behaves_like 'sets pinned posts'
end
end
context 'when the endpoint is a Collection' do
before do
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
@ -120,7 +169,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
before do
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' })
subject.call(actor, note: true, hashtag: false)
subject
end
it 'sets expected posts as pinned posts' do
@ -156,7 +205,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
before do
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable), headers: { 'Content-Type': 'application/activity+json' })
subject.call(actor, note: true, hashtag: false)
subject
end
it 'sets expected posts as pinned posts' do