Compare commits

..

29 Commits

Author SHA1 Message Date
Claire
6558a1e07a Merge pull request #3309 from ClearlyClaire/glitch-soc/merge-4.4
Merge upstream changes up to d5f12debe0 into stable-4.4
2025-12-08 17:29:58 +01:00
Echo
b64101ee64 [Glitch] Fixes YouTube embeds
Port 9bc9ebc59e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:56:28 +01:00
Bruno Viveiros
0bde273b3d [Glitch] fix: YouTube iframe being able to start at a defined time
Port bdff970a5e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:56:20 +01:00
Claire
22fe977ffe [Glitch] Fix error handling when re-fetching already-known statuses
Port edfbcfb3f5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-08 16:55:59 +01:00
Claire
8e945bef2a Merge commit 'd5f12debe0fdd9033f3ef7ee84f9ddf366ffc2e3' into glitch-soc/merge-4.4 2025-12-08 16:54:12 +01:00
Claire
d5f12debe0 Bump version to v4.4.10 (#37162) 2025-12-08 16:20:18 +01:00
Claire
5d0ec718fd Merge commit from fork 2025-12-08 15:44:08 +01:00
Echo
c7aa312307 Fixes YouTube embeds (#37126) 2025-12-05 11:15:08 +01:00
Bruno Viveiros
dc1d4eda7c fix: YouTube iframe being able to start at a defined time (#26584) 2025-12-05 11:15:08 +01:00
Claire
931a29b4f3 Fix streamed quoted polls not being hydrated correctly (#37118) 2025-12-05 11:15:08 +01:00
Claire
99b2307350 Fix error handling when re-fetching already-known statuses (#37077) 2025-12-05 11:15:08 +01:00
Claire
375f2e6ebf Increase HTTP read timeout for expensive S3 batch delete operation (#37004) 2025-12-05 11:15:08 +01:00
Matt Jankowski
f0a1da78ba Suggest ES image version 7.17.29 in docker compose (#36972) 2025-12-05 11:15:08 +01:00
Claire
b554ecfcb4 Merge pull request #3292 from ClearlyClaire/glitch-soc/merge-4.4
Merge upstream changes up to 01cf5c103d into stable-4.4
2025-11-20 15:25:12 +01:00
Claire
50244ba682 Merge commit '01cf5c103de6c46b649d4db38b517b51f8bdfb56' into glitch-soc/merge-4.4 2025-11-20 14:58:00 +01:00
Claire
01cf5c103d Bump version to v4.4.9 (#36946) 2025-11-20 14:41:12 +01:00
Claire
5bda54d15a Merge pull request #3290 from ClearlyClaire/glitch-soc/merge-4.4
Merge upstream changes up to c49e261ad0 into stable-4.4
2025-11-19 22:59:55 +01:00
Claire
07f5573cd6 [Glitch] Fix filters not being applied to quotes in detailed view
Port 16ee628d24 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-19 22:30:31 +01:00
Claire
2b0b537152 Merge commit 'c49e261ad07d46515af50cdf103524ecf554b2f8' into glitch-soc/merge-4.4 2025-11-19 22:29:14 +01:00
Claire
c49e261ad0 Update dependency glob (#36942) 2025-11-19 16:29:45 +01:00
Shugo Maeda
915bcb267f Fix ArgumentError of tootctl upgrade storage-schema (#36914) 2025-11-19 13:55:30 +01:00
Claire
ff37011057 update dependency js-yaml to v4.1.1 2025-11-19 13:54:58 +01:00
Claire
8f5e95a159 Fix Update importing old previously-unknown activities and treating them as recent ones (#36848) 2025-11-19 13:50:16 +01:00
Claire
16ee628d24 Fix filters not being applied to quotes in detailed view (#36843) 2025-11-19 13:47:19 +01:00
Claire
64a0b060a8 Update security policy for 4.3 (#36755) 2025-11-06 14:58:24 +01:00
Claire
fa52f4361a Merge pull request #3248 from ClearlyClaire/glitch-soc/merge-4.4
Merge upstream changes up to c2fb12d22d to stable-4.4
2025-10-21 15:33:28 +02:00
Claire
7f7d6697c1 Merge commit 'c2fb12d22d52872b5905822c3d05f65516fa7f1d' into glitch-soc/merge-4.4 2025-10-21 15:14:15 +02:00
Claire
c2fb12d22d Bump version to v4.4.8 (#36542) 2025-10-21 15:12:37 +02:00
Claire
2dc4552229 Merge commit from fork
* Add validation to reject quotes of reblogs

* Do not process quotes of reblogs as potentially valid quotes

* Refuse to serve quoted reblogs over REST API
2025-10-21 15:00:28 +02:00
39 changed files with 322 additions and 76 deletions

View File

@@ -2,6 +2,34 @@
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.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
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire)
- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire)
## [4.4.8] - 2025-10-21
### Security
- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6))
## [4.4.7] - 2025-10-15 ## [4.4.7] - 2025-10-15
### Fixed ### Fixed

View File

@@ -16,6 +16,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| Version | Supported | | Version | Supported |
| ------- | ---------------- | | ------- | ---------------- |
| 4.4.x | Yes | | 4.4.x | Yes |
| 4.3.x | Yes | | 4.3.x | Until 2026-05-06 |
| 4.2.x | Until 2026-01-08 | | 4.2.x | Until 2026-01-08 |
| < 4.2 | No | | < 4.2 | No |

View File

@@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
def set_status def set_status
@status = @account.statuses.find(params[:status_id]) @status = @account.statuses.find(params[:status_id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
def set_status def set_status
@status = @account.statuses.find(params[:status_id]) @status = @account.statuses.find(params[:status_id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
def set_status def set_status
@status = @account.statuses.find(params[:status_id]) @status = @account.statuses.find(params[:status_id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
def set_poll def set_poll
@poll = Poll.find(params[:poll_id]) @poll = Poll.find(params[:poll_id])
authorize @poll.status, :show? authorize @poll.status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
def set_poll def set_poll
@poll = Poll.find(params[:id]) @poll = Poll.find(params[:id])
authorize @poll.status, :show? authorize @poll.status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
def set_status def set_status
@status = Status.find(params[:status_id]) @status = Status.find(params[:status_id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end
end end

View File

@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
bookmark&.destroy! bookmark&.destroy!
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) 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 not_found
end end
end end

View File

@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } }) 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 render json: @status, serializer: REST::StatusSerializer, relationships: relationships
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end
end end

View File

@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } }) 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 render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
def set_reblog def set_reblog
@reblog = Status.find(params[:status_id]) @reblog = Status.find(params[:status_id])
authorize @reblog, :show? authorize @reblog, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -129,7 +129,7 @@ class Api::V1::StatusesController < Api::BaseController
def set_status def set_status
@status = Status.find(params[:id]) @status = Status.find(params[:id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
def set_status def set_status
@status = Status.find(params[:id]) @status = Status.find(params[:id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end
end end

View File

@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
def set_resource def set_resource
@resource = located_resource @resource = located_resource
authorize(@resource, :show?) if @resource.is_a?(Status) authorize(@resource, :show?) if @resource.is_a?(Status)
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -34,7 +34,7 @@ class MediaController < ApplicationController
def verify_permitted_status! def verify_permitted_status!
authorize @media_attachment.status, :show? authorize @media_attachment.status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -59,7 +59,7 @@ class StatusesController < ApplicationController
def set_status def set_status
@status = @account.statuses.find(params[:id]) @status = @account.statuses.find(params[:id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found not_found
end end

View File

@@ -78,6 +78,8 @@ export function fetchStatus(id, {
dispatch(fetchStatusSuccess(skipLoading)); dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => { }).catch(error => {
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId)); dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
if (error.status === 404)
dispatch(deleteFromTimelines(id));
}); });
}; };
} }

View File

@@ -26,18 +26,23 @@ const getHostname = url => {
const domParser = new DOMParser(); const domParser = new DOMParser();
const addAutoPlay = html => { const handleIframeUrl = (html, url, providerName) => {
const document = domParser.parseFromString(html, 'text/html').documentElement; const document = domParser.parseFromString(html, 'text/html').documentElement;
const iframe = document.querySelector('iframe'); const iframe = document.querySelector('iframe');
const startTime = new URL(url).searchParams.get('t')
if (iframe) { if (iframe) {
if (iframe.src.indexOf('?') !== -1) { const iframeUrl = new URL(iframe.src)
iframe.src += '&';
} else { iframeUrl.searchParams.set('autoplay', 1)
iframe.src += '?'; 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, // 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 // so we need to get innerHTML out of the body and not the entire document
@@ -103,7 +108,7 @@ export default class Card extends PureComponent {
renderVideo () { renderVideo () {
const { card } = this.props; 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 ( return (
<div <div

View File

@@ -421,6 +421,7 @@ export const DetailedStatus: React.FC<{
<QuotedStatus <QuotedStatus
quote={status.get('quote')} quote={status.get('quote')}
parentQuotePostId={status.get('id')} parentQuotePostId={status.get('id')}
contextType='thread'
/> />
)} )}
</> </>

View File

@@ -64,6 +64,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('flavours/glitch/models/status').Status>} */ /** @type {ImmutableMap<string, import('flavours/glitch/models/status').Status>} */
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@@ -75,11 +79,10 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.id, 'isLoading'], true); return state.setIn([action.id, 'isLoading'], true);
case STATUS_FETCH_FAIL: { case STATUS_FETCH_FAIL: {
if (action.parentQuotePostId && action.error.status === 404) { if (action.parentQuotePostId && action.error.status === 404) {
return state return removeStatusStub(state, action.id)
.delete(action.id)
.setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted') .setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted')
} else { } else {
return state.delete(action.id); return removeStatusStub(state, action.id);
} }
} }
case STATUS_IMPORT: case STATUS_IMPORT:

View File

@@ -78,6 +78,8 @@ export function fetchStatus(id, {
dispatch(fetchStatusSuccess(skipLoading)); dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => { }).catch(error => {
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId)); dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
if (error.status === 404)
dispatch(deleteFromTimelines(id));
}); });
}; };
} }

View File

@@ -37,18 +37,23 @@ const getHostname = url => {
const domParser = new DOMParser(); const domParser = new DOMParser();
const addAutoPlay = html => { const handleIframeUrl = (html, url, providerName) => {
const document = domParser.parseFromString(html, 'text/html').documentElement; const document = domParser.parseFromString(html, 'text/html').documentElement;
const iframe = document.querySelector('iframe'); const iframe = document.querySelector('iframe');
const startTime = new URL(url).searchParams.get('t')
if (iframe) { if (iframe) {
if (iframe.src.indexOf('?') !== -1) { const iframeUrl = new URL(iframe.src)
iframe.src += '&';
} else { iframeUrl.searchParams.set('autoplay', 1)
iframe.src += '?'; 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, // 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 // 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 () { renderVideo () {
const { card } = this.props; 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 ( return (
<div <div

View File

@@ -384,6 +384,7 @@ export const DetailedStatus: React.FC<{
<QuotedStatus <QuotedStatus
quote={status.get('quote')} quote={status.get('quote')}
parentQuotePostId={status.get('id')} parentQuotePostId={status.get('id')}
contextType='thread'
/> />
)} )}
</> </>

View File

@@ -64,6 +64,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>} */ /** @type {ImmutableMap<string, import('mastodon/models/status').Status>} */
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@@ -75,11 +79,10 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.id, 'isLoading'], true); return state.setIn([action.id, 'isLoading'], true);
case STATUS_FETCH_FAIL: { case STATUS_FETCH_FAIL: {
if (action.parentQuotePostId && action.error.status === 404) { if (action.parentQuotePostId && action.error.status === 404) {
return state return removeStatusStub(state, action.id)
.delete(action.id)
.setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted') .setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted')
} else { } else {
return state.delete(action.id); return removeStatusStub(state, action.id);
} }
} }
case STATUS_IMPORT: case STATUS_IMPORT:

View File

@@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::Activity::Update < ActivityPub::Activity class ActivityPub::Activity::Update < ActivityPub::Activity
# Updates to unknown objects older than that are ignored
OBJECT_AGE_THRESHOLD = 1.day
def perform def perform
@account.schedule_refresh_if_stale! @account.schedule_refresh_if_stale!
@@ -28,6 +31,9 @@ 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
return if @status.nil? && 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
@@ -35,4 +41,10 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id]) ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id])
end end
def object_too_old?
@object['published'].present? && @object['published'].to_datetime < OBJECT_AGE_THRESHOLD.ago
rescue Date::Error
false
end
end end

View File

@@ -112,10 +112,12 @@ class AttachmentBatch
keys.each_slice(LIMIT) do |keys_slice| keys.each_slice(LIMIT) do |keys_slice|
logger.debug { "Deleting #{keys_slice.size} objects" } logger.debug { "Deleting #{keys_slice.size} objects" }
with_overridden_timeout(bucket.client, 120) do
bucket.delete_objects(delete: { bucket.delete_objects(delete: {
objects: keys_slice.map { |key| { key: key } }, objects: keys_slice.map { |key| { key: key } },
quiet: true, quiet: true,
}) })
end
rescue => e rescue => e
retries += 1 retries += 1
@@ -134,6 +136,20 @@ class AttachmentBatch
@bucket ||= records.first.public_send(@attachment_names.first).s3_bucket @bucket ||= records.first.public_send(@attachment_names.first).s3_bucket
end 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 def nullified_attributes
@attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil) @attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil)
end end

View File

@@ -26,12 +26,7 @@ class StatusCacheHydrator
def hydrate_non_reblog_payload(empty_payload, account_id, nested: false) def hydrate_non_reblog_payload(empty_payload, account_id, nested: false)
empty_payload.tap do |payload| empty_payload.tap do |payload|
fill_status_payload(payload, @status, account_id, nested:) fill_status_payload(payload, @status, account_id, fresh: !nested, nested:)
if payload[:poll]
payload[:poll][:voted] = @status.account_id == account_id
payload[:poll][:own_votes] = []
end
end end
end end
@@ -45,18 +40,7 @@ class StatusCacheHydrator
# used to create the status, we need to hydrate it here too # 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 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, nested:) fill_status_payload(payload[:reblog], @status.reblog, account_id, fresh: false, 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
payload[:filtered] = payload[:reblog][:filtered] payload[:filtered] = payload[:reblog][:filtered]
payload[:favourited] = payload[:reblog][:favourited] payload[:favourited] = payload[:reblog][:favourited]
@@ -64,7 +48,7 @@ class StatusCacheHydrator
end end
end end
def fill_status_payload(payload, status, account_id, nested: false) def fill_status_payload(payload, status, account_id, nested: false, fresh: true)
payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: status.id) 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[: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) payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: status.conversation_id)
@@ -72,6 +56,30 @@ class StatusCacheHydrator
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
payload[:filtered] = mapped_applied_custom_filter(account_id, status) payload[:filtered] = mapped_applied_custom_filter(account_id, status)
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id, nested:) if payload[:quote] 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 end
def hydrate_quote_payload(empty_payload, quote, account_id, nested: false) def hydrate_quote_payload(empty_payload, quote, account_id, nested: false)

View File

@@ -34,6 +34,7 @@ class Quote < ApplicationRecord
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? } before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? }
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? } validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
validate :validate_visibility validate :validate_visibility
validate :validate_original_quoted_status
def accept! def accept!
update!(state: :accepted) update!(state: :accepted)
@@ -70,6 +71,10 @@ class Quote < ApplicationRecord
errors.add(:quoted_status_id, :visibility_mismatch) errors.add(:quoted_status_id, :visibility_mismatch)
end end
def validate_original_quoted_status
errors.add(:quoted_status_id, :reblog_unallowed) if quoted_status&.reblog?
end
def set_activity_uri def set_activity_uri
self.activity_uri = [ActivityPub::TagManager.instance.uri_for(account), '/quote_requests/', SecureRandom.uuid].join self.activity_uri = [ActivityPub::TagManager.instance.uri_for(account), '/quote_requests/', SecureRandom.uuid].join
end end

View File

@@ -14,7 +14,7 @@ class REST::BaseQuoteSerializer < ActiveModel::Serializer
end end
def quoted_status def quoted_status
object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered_for_quote? object.quoted_status if object.accepted? && object.quoted_status.present? && !object.quoted_status&.reblog? && !status_filter.filtered_for_quote?
end end
private private

View File

@@ -72,7 +72,7 @@ class ActivityPub::VerifyQuoteService < BaseService
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id, depth: @depth + 1) status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id, depth: @depth + 1)
@quote.update(quoted_status: status) if status.present? @quote.update(quoted_status: status) if status.present? && !status.reblog?
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
@fetching_error = e @fetching_error = e
end end
@@ -90,7 +90,7 @@ class ActivityPub::VerifyQuoteService < BaseService
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth) status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth)
if status.present? if status.present? && !status.reblog?
@quote.update(quoted_status: status) @quote.update(quoted_status: status)
true true
else else

View File

@@ -27,7 +27,7 @@ services:
# es: # es:
# restart: always # restart: always
# image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4 # image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
# environment: # environment:
# - "ES_JAVA_OPTS=-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true" # - "ES_JAVA_OPTS=-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true"
# - "xpack.license.self_generated.type=basic" # - "xpack.license.self_generated.type=basic"
@@ -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/glitch-soc/mastodon:v4.4.7 image: ghcr.io/glitch-soc/mastodon:v4.4.10
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/glitch-soc/mastodon-streaming:v4.4.7 image: ghcr.io/glitch-soc/mastodon-streaming:v4.4.10
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/glitch-soc/mastodon:v4.4.7 image: ghcr.io/glitch-soc/mastodon:v4.4.10
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View File

@@ -123,12 +123,12 @@ module Mastodon::CLI
progress.log("Moving #{previous_path} to #{upgraded_path}") if options[:verbose] progress.log("Moving #{previous_path} to #{upgraded_path}") if options[:verbose]
begin begin
move_previous_to_upgraded move_previous_to_upgraded(previous_path, upgraded_path)
rescue => e rescue => e
progress.log(pastel.red("Error processing #{previous_path}: #{e}")) progress.log(pastel.red("Error processing #{previous_path}: #{e}"))
success = false success = false
remove_directory remove_directory(upgraded_path)
end end
end end

View File

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

View File

@@ -76,7 +76,7 @@
"http-link-header": "^1.1.1", "http-link-header": "^1.1.1",
"immutable": "^4.3.0", "immutable": "^4.3.0",
"intl-messageformat": "^10.7.16", "intl-messageformat": "^10.7.16",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.1",
"lande": "^1.0.10", "lande": "^1.0.10",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marky": "^1.2.5", "marky": "^1.2.5",

View File

@@ -1096,6 +1096,60 @@ RSpec.describe ActivityPub::Activity::Create do
end end
end end
context 'with a quote of a known reblog that is otherwise valid' do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account, reblog: Fabricate(:status)) }
let(:approval_uri) { 'https://quoted.example.com/quote-approval' }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri
)
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': [
'https://www.w3.org/ns/activitystreams',
{
QuoteAuthorization: 'https://w3id.org/fep/044f#QuoteAuthorization',
gts: 'https://gotosocial.org/ns#',
interactionPolicy: {
'@id': 'gts:interactionPolicy',
'@type': '@id',
},
interactingObject: {
'@id': 'gts:interactingObject',
'@type': '@id',
},
interactionTarget: {
'@id': 'gts:interactionTarget',
'@type': '@id',
},
},
],
type: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: object_json[:id],
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'creates a status without the verified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote.state).to_not eq 'accepted'
expect(status.quote.quoted_status).to be_nil
end
end
context 'when a vote to a local poll' do context 'when a vote to a local poll' do
let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
let!(:local_status) { Fabricate(:status, poll: poll) } let!(:local_status) { Fabricate(:status, poll: poll) }

View File

@@ -34,6 +34,8 @@ RSpec.describe ActivityPub::Activity do
} }
end end
let(:publication_date) { 1.hour.ago.utc }
let(:create_json) do let(:create_json) do
{ {
'@context': [ '@context': [
@@ -52,7 +54,7 @@ RSpec.describe ActivityPub::Activity do
'https://www.w3.org/ns/activitystreams#Public', 'https://www.w3.org/ns/activitystreams#Public',
], ],
content: 'foo', content: 'foo',
published: '2025-05-24T11:03:10Z', published: publication_date.iso8601,
quote: ActivityPub::TagManager.instance.uri_for(quoted_status), quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
}, },
}.deep_stringify_keys }.deep_stringify_keys
@@ -77,7 +79,7 @@ RSpec.describe ActivityPub::Activity do
'https://www.w3.org/ns/activitystreams#Public', 'https://www.w3.org/ns/activitystreams#Public',
], ],
content: 'foo', content: 'foo',
published: '2025-05-24T11:03:10Z', published: publication_date.iso8601,
quote: ActivityPub::TagManager.instance.uri_for(quoted_status), quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri, quoteAuthorization: approval_uri,
}, },

View File

@@ -164,6 +164,28 @@ RSpec.describe StatusCacheHydrator do
end end
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 context 'when the quoted post matches account filters' do
let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') } let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }

View File

@@ -733,6 +733,72 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end end
end end
context 'when the status adds a verifiable quote of a reblog through an explicit update' do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account, reblog: Fabricate(:status)) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri,
}
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': [
'https://www.w3.org/ns/activitystreams',
{
QuoteAuthorization: 'https://w3id.org/fep/044f#QuoteAuthorization',
gts: 'https://gotosocial.org/ns#',
interactionPolicy: {
'@id': 'gts:interactionPolicy',
'@type': '@id',
},
interactingObject: {
'@id': 'gts:interactingObject',
'@type': '@id',
},
interactionTarget: {
'@id': 'gts:interactionTarget',
'@type': '@id',
},
},
],
type: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the approval URI but does not verify the quote' do
expect { subject.call(status, json, json) }
.to change(status, :quote).from(nil)
expect(status.quote.approval_uri).to eq approval_uri
expect(status.quote.state).to_not eq 'accepted'
expect(status.quote.quoted_status).to be_nil
end
end
context 'when the status adds a unverifiable quote through an implicit update' do context 'when the status adds a unverifiable quote through an implicit update' do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') } let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) } let(:quoted_status) { Fabricate(:status, account: quoted_account) }

View File

@@ -2689,7 +2689,7 @@ __metadata:
husky: "npm:^9.0.11" husky: "npm:^9.0.11"
immutable: "npm:^4.3.0" immutable: "npm:^4.3.0"
intl-messageformat: "npm:^10.7.16" intl-messageformat: "npm:^10.7.16"
js-yaml: "npm:^4.1.0" js-yaml: "npm:^4.1.1"
lande: "npm:^1.0.10" lande: "npm:^1.0.10"
lint-staged: "npm:^16.0.0" lint-staged: "npm:^16.0.0"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
@@ -7769,8 +7769,8 @@ __metadata:
linkType: hard linkType: hard
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1": "glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1":
version: 10.4.5 version: 10.5.0
resolution: "glob@npm:10.4.5" resolution: "glob@npm:10.5.0"
dependencies: dependencies:
foreground-child: "npm:^3.1.0" foreground-child: "npm:^3.1.0"
jackspeak: "npm:^3.1.2" jackspeak: "npm:^3.1.2"
@@ -7780,7 +7780,7 @@ __metadata:
path-scurry: "npm:^1.11.1" path-scurry: "npm:^1.11.1"
bin: bin:
glob: dist/esm/bin.mjs glob: dist/esm/bin.mjs
checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828
languageName: node languageName: node
linkType: hard linkType: hard
@@ -8762,6 +8762,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"js-yaml@npm:^4.1.1":
version: 4.1.1
resolution: "js-yaml@npm:4.1.1"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7
languageName: node
linkType: hard
"jsdoc-type-pratt-parser@npm:~4.1.0": "jsdoc-type-pratt-parser@npm:~4.1.0":
version: 4.1.0 version: 4.1.0
resolution: "jsdoc-type-pratt-parser@npm:4.1.0" resolution: "jsdoc-type-pratt-parser@npm:4.1.0"