Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/activitypub/likes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/activitypub/replies_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/activitypub/shares_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/polls/votes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/polls_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/statuses/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/controllers/api/v1/statuses/bookmarks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/controllers/api/v1/statuses/favourites_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions app/controllers/api/v1/statuses/reblogs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/statuses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/web/embeds_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/controllers/authorize_interactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/media_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/statuses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def set_status
else
authorize @status, :show?
end
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

Expand Down
2 changes: 2 additions & 0 deletions app/javascript/mastodon/actions/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
};
}
Expand Down
19 changes: 12 additions & 7 deletions app/javascript/mastodon/features/status/components/card.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div
Expand Down
9 changes: 6 additions & 3 deletions app/javascript/mastodon/reducers/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ const statusTranslateUndo = (state, id) => {
});
};

const removeStatusStub = (state, id) => {
return state.getIn([id, 'id']) ? state.deleteIn([id, 'isLoading']) : state.delete(id);
}


/** @type {ImmutableMap<string, import('mastodon/models/status').Status>} */
const initialState = ImmutableMap();
Expand All @@ -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:
Expand Down
24 changes: 20 additions & 4 deletions app/lib/attachment_batch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
46 changes: 27 additions & 19 deletions app/lib/status_cache_hydrator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -47,26 +42,15 @@ 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]
payload[:reblogged] = payload[:reblog][:reblogged]
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)
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/mastodon/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def kmyblue_major
end

def kmyblue_minor
10
11
end

def kmyblue_flag
Expand All @@ -31,7 +31,7 @@ def minor
end

def patch
9
10
end

def default_prerelease
Expand Down
Loading
Loading