diff --git a/CHANGELOG.md b/CHANGELOG.md index 7235d309aa3b5b..251354f3cef7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [4.4.10] - 2025-12-08 + +### Security + +- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8)) + +### Fixed + +- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima) +- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros) +- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire) +- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire) +- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire) + ## [4.4.9] - 2025-11-20 ### Fixed diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb index 4aa6a4a771f156..6de110a272ee97 100644 --- a/app/controllers/activitypub/likes_controller.rb +++ b/app/controllers/activitypub/likes_controller.rb @@ -22,7 +22,7 @@ def pundit_user def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 0a19275d38e942..9f4b934d14119e 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -25,7 +25,7 @@ def pundit_user def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb index 65b4a5b3831326..8258278e5833b2 100644 --- a/app/controllers/activitypub/shares_controller.rb +++ b/app/controllers/activitypub/shares_controller.rb @@ -22,7 +22,7 @@ def pundit_user def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 2833687a38cb1f..659e52bac48fba 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -17,7 +17,7 @@ def create def set_poll @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index b4c25476e8544b..bf30c178571e93 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -17,7 +17,7 @@ def show def set_poll @poll = Poll.find(params[:id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses/base_controller.rb b/app/controllers/api/v1/statuses/base_controller.rb index 3f56b68bcf41f1..0c4c49a2c3ff26 100644 --- a/app/controllers/api/v1/statuses/base_controller.rb +++ b/app/controllers/api/v1/statuses/base_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController def set_status @status = Status.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index 109b12f467efe0..b4b976ac3c5ce6 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -23,7 +23,7 @@ def destroy bookmark&.destroy! render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index dbc75a03644dbb..17eeccdbe749f0 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -25,7 +25,7 @@ def destroy relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } }) render json: @status, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index 971b054c548f19..6a5788fca3015d 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -36,7 +36,7 @@ def destroy relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } }) render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end @@ -45,7 +45,7 @@ def destroy def set_reblog @reblog = Status.find(params[:status_id]) authorize @reblog, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 1217b70752ca18..6ea3be6ecd2823 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -141,7 +141,7 @@ def set_statuses def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index f82c1c50d79502..fba56b405864ef 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -30,7 +30,7 @@ def show def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index 99eed018b070ed..03cad3e3175f03 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -21,7 +21,7 @@ def show def set_resource @resource = located_resource authorize(@resource, :show?) if @resource.is_a?(Status) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 9d10468e69330e..0590ea40270cd3 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -34,7 +34,7 @@ def set_media_attachment def verify_permitted_status! authorize @media_attachment.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 4779f79c5a4fd9..c28af592f914b8 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -66,7 +66,7 @@ def set_status else authorize @status, :show? end - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index dec90d99533f8a..44bef9711a70fc 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -88,6 +88,8 @@ export function fetchStatus(id, { dispatch(fetchStatusSuccess(skipLoading)); }).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId)); + if (error.status === 404) + dispatch(deleteFromTimelines(id)); }); }; } diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx index f8d5a26aff47bf..6ed856da1c2db8 100644 --- a/app/javascript/mastodon/features/status/components/card.jsx +++ b/app/javascript/mastodon/features/status/components/card.jsx @@ -37,18 +37,23 @@ const getHostname = url => { const domParser = new DOMParser(); -const addAutoPlay = html => { +const handleIframeUrl = (html, url, providerName) => { const document = domParser.parseFromString(html, 'text/html').documentElement; const iframe = document.querySelector('iframe'); + const startTime = new URL(url).searchParams.get('t') if (iframe) { - if (iframe.src.indexOf('?') !== -1) { - iframe.src += '&'; - } else { - iframe.src += '?'; + const iframeUrl = new URL(iframe.src) + + iframeUrl.searchParams.set('autoplay', 1) + iframeUrl.searchParams.set('auto_play', 1) + + if (providerName === 'YouTube') { + iframeUrl.searchParams.set('start', startTime || ''); + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; } - iframe.src += 'autoplay=1&auto_play=1'; + iframe.src = iframeUrl.href // DOM parser creates html/body elements around original HTML fragment, // so we need to get innerHTML out of the body and not the entire document @@ -114,7 +119,7 @@ export default class Card extends PureComponent { renderVideo () { const { card } = this.props; - const content = { __html: addAutoPlay(card.get('html')) }; + const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) }; return (
{ }); }; +const removeStatusStub = (state, id) => { + return state.getIn([id, 'id']) ? state.deleteIn([id, 'isLoading']) : state.delete(id); +} + /** @type {ImmutableMap} */ const initialState = ImmutableMap(); @@ -107,11 +111,10 @@ export default function statuses(state = initialState, action) { return state.setIn([action.id, 'isLoading'], true); case STATUS_FETCH_FAIL: { if (action.parentQuotePostId && action.error.status === 404) { - return state - .delete(action.id) + return removeStatusStub(state, action.id) .setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted') } else { - return state.delete(action.id); + return removeStatusStub(state, action.id); } } case STATUS_IMPORT: diff --git a/app/lib/attachment_batch.rb b/app/lib/attachment_batch.rb index 374abfac49038d..1443a1ec60cb39 100644 --- a/app/lib/attachment_batch.rb +++ b/app/lib/attachment_batch.rb @@ -112,10 +112,12 @@ def remove_files keys.each_slice(LIMIT) do |keys_slice| logger.debug { "Deleting #{keys_slice.size} objects" } - bucket.delete_objects(delete: { - objects: keys_slice.map { |key| { key: key } }, - quiet: true, - }) + with_overridden_timeout(bucket.client, 120) do + bucket.delete_objects(delete: { + objects: keys_slice.map { |key| { key: key } }, + quiet: true, + }) + end rescue => e retries += 1 @@ -134,6 +136,20 @@ def bucket @bucket ||= records.first.public_send(@attachment_names.first).s3_bucket end + # Currently, the aws-sdk-s3 gem does not offer a way to cleanly override the timeout + # per-request. So we change the client's config instead. As this client will likely + # be re-used for other jobs, restore its original configuration in an `ensure` block. + def with_overridden_timeout(s3_client, longer_read_timeout) + original_timeout = s3_client.config.http_read_timeout + s3_client.config.http_read_timeout = [original_timeout, longer_read_timeout].max + + begin + yield + ensure + s3_client.config.http_read_timeout = original_timeout + end + end + def nullified_attributes @attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil) end diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb index 45897712296f59..817868100aadf1 100644 --- a/app/lib/status_cache_hydrator.rb +++ b/app/lib/status_cache_hydrator.rb @@ -28,12 +28,7 @@ def hydrate(account_id, nested: false) def hydrate_non_reblog_payload(empty_payload, account_id, account, nested: false) empty_payload.tap do |payload| - fill_status_payload(payload, @status, account_id, account, nested:) - - if payload[:poll] - payload[:poll][:voted] = @status.account_id == account_id - payload[:poll][:own_votes] = [] - end + fill_status_payload(payload, @status, account_id, account, fresh: !nested, nested:) end end @@ -47,18 +42,7 @@ def hydrate_reblog_payload(empty_payload, account_id, account, nested: false) # used to create the status, we need to hydrate it here too payload[:reblog][:application] = payload_reblog_application if payload[:reblog][:application].nil? && @status.reblog.account_id == account_id - fill_status_payload(payload[:reblog], @status.reblog, account_id, account, nested:) - - if payload[:reblog][:poll] - if @status.reblog.account_id == account_id - payload[:reblog][:poll][:voted] = true - payload[:reblog][:poll][:own_votes] = [] - else - own_votes = PollVote.where(poll_id: @status.reblog.poll_id, account_id: account_id).pluck(:choice) - payload[:reblog][:poll][:voted] = !own_votes.empty? - payload[:reblog][:poll][:own_votes] = own_votes - end - end + fill_status_payload(payload[:reblog], @status.reblog, account_id, account, fresh: false, nested:) payload[:filtered] = payload[:reblog][:filtered] payload[:favourited] = payload[:reblog][:favourited] @@ -66,7 +50,7 @@ def hydrate_reblog_payload(empty_payload, account_id, account, nested: false) end end - def fill_status_payload(payload, status, account_id, account, nested: false) + def fill_status_payload(payload, status, account_id, account, nested: false, fresh: true) payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: status.id) payload[:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: status.id) payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: status.conversation_id) @@ -75,6 +59,30 @@ def fill_status_payload(payload, status, account_id, account, nested: false) payload[:filtered] = mapped_applied_custom_filter(account_id, status) payload[:emoji_reactions] = status.emoji_reactions_grouped_by_name(account) payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id, nested:) if payload[:quote] + + if payload[:poll] + if fresh + # If the status is brand new, we don't need to look up votes in database + payload[:poll][:voted] = status.account_id == account_id + payload[:poll][:own_votes] = [] + elsif status.account_id == account_id + payload[:poll][:voted] = true + payload[:poll][:own_votes] = [] + else + own_votes = PollVote.where(poll_id: status.poll_id, account_id: account_id).pluck(:choice) + payload[:poll][:voted] = !own_votes.empty? + payload[:poll][:own_votes] = own_votes + end + end + + # Nested statuses are more likely to have a stale cache + fill_status_stats(payload, status) if nested + end + + def fill_status_stats(payload, status) + payload[:replies_count] = status.replies_count + payload[:reblogs_count] = status.untrusted_reblogs_count || status.reblogs_count + payload[:favourites_count] = status.untrusted_favourites_count || status.favourites_count end def hydrate_quote_payload(empty_payload, quote, account_id, nested: false) diff --git a/docker-compose.yml b/docker-compose.yml index d0b470f377d212..30aaf9f4c3b93e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: # es: # restart: always - # image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4 + # image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29 # environment: # - "ES_JAVA_OPTS=-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true" # - "xpack.license.self_generated.type=basic" @@ -59,7 +59,7 @@ services: web: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes build: . - image: kmyblue:19.10-lts + image: kmyblue:19.11-lts restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -83,7 +83,7 @@ services: build: dockerfile: ./streaming/Dockerfile context: . - image: kmyblue-streaming:19.10-lts + image: kmyblue-streaming:19.11-lts restart: always env_file: .env.production command: node ./streaming/index.js @@ -101,7 +101,7 @@ services: sidekiq: build: . - image: kmyblue:19.10-lts + image: kmyblue:19.11-lts restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index d1c702275ebd97..7c057481873809 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ def kmyblue_major end def kmyblue_minor - 10 + 11 end def kmyblue_flag @@ -31,7 +31,7 @@ def minor end def patch - 9 + 10 end def default_prerelease diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb index e56393da1dd6ea..3c1aed4a9a02eb 100644 --- a/spec/lib/status_cache_hydrator_spec.rb +++ b/spec/lib/status_cache_hydrator_spec.rb @@ -164,6 +164,28 @@ end end + context 'when the quoted post has a poll authored by the user' do + let(:poll) { Fabricate(:poll, account: account) } + let(:quoted_status) { Fabricate(:status, poll: poll, account: account) } + + it 'renders the same attributes as a full render' do + expect(subject).to eql(compare_to_hash) + end + end + + context 'when the quoted post has been voted in' do + let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } + let(:quoted_status) { Fabricate(:status, poll: poll) } + + before do + VoteService.new.call(account, poll, [0]) + end + + it 'renders the same attributes as a full render' do + expect(subject).to eql(compare_to_hash) + end + end + context 'when the quoted post matches account filters' do let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }