Merge pull request #3619 from glitch-soc/glitch-soc/merge-4.6

Merge upstream changes up to 1440d55b13 into stable-4.6
This commit is contained in:
Claire authored and GitHub committed 2026-08-07 08:17:33 +02:00
commit 6f8664c82e
20 files changed
+237 -105

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## [4.6.5] - 2026-08-06
- Fix Collection items being rejected because of incorrect attribute being read (#40052 by @shleeable)
- Fix typo in embedded quote handling code (#40049 by @shleeable)
- Fix account merging worker incorrectly merging `Appeal` and `AccountWarning` records (#39982 by @shleeable)
- Fix off-by-one in handling of updated remote posts allowing up to 5 attachments (#39978 by @shleeable)
- Fix collection items limit not being consistently applied (#39969 by @oneiros)
- Fix oversized profile image crop uploads (#39958 by @sharlayan)
- Fix emoji autocomplete sometimes suggesting emojis for earlier keystrokes (#39947 by @ChaosExAnima)
## [4.6.4] - 2026-07-27
### Security
@@ -20,6 +20,8 @@ import { updateTimeline } from './timelines';
let fetchComposeSuggestionsAccountsController;
/** @type {AbortController | undefined} */
let fetchComposeSuggestionsTagsController;
/** @type {AbortController | undefined} */
let searchComposeSuggestionsEmojiController;
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
@@ -531,9 +533,8 @@ export function undoUploadCompose(media_id) {
}
export function clearComposeSuggestions() {
if (fetchComposeSuggestionsAccountsController) {
fetchComposeSuggestionsAccountsController.abort();
}
fetchComposeSuggestionsAccountsController?.abort();
searchComposeSuggestionsEmojiController?.abort();
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
};
@@ -566,12 +567,25 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
// Right now we are hard-coding the locale to English since the picker search only supports English.
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
const results = await emojiMartSearch(token, 'en', 5);
dispatch(readyComposeSuggestionsEmojis(token, results));
};
const fetchComposeSuggestionsEmojis = (dispatch, token) => {
dispatch(clearComposeSuggestions());
searchComposeSuggestionsEmojiController = new AbortController();
void emojiMartSearch({
token,
// Right now we are hard-coding the locale to English since the picker search only supports English.
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
locale: 'en',
limit: 5,
signal: searchComposeSuggestionsEmojiController.signal,
}).then((results) => {
if (results) {
dispatch(readyComposeSuggestionsEmojis(token, results));
}
}).finally(() => {
searchComposeSuggestionsEmojiController = undefined;
});
}
const fetchComposeSuggestionsTags = throttle((dispatch, token) => {
if (fetchComposeSuggestionsTagsController) {
@@ -94,12 +94,12 @@ export const ImageUploadModal: FC<
setStep('select');
return;
}
void calculateCroppedImage(imageSrc, crop).then((blob) => {
void calculateCroppedImage(imageSrc, crop, location).then((blob) => {
setImageBlob(blob);
setStep('alt');
});
},
[imageSrc],
[imageSrc, location],
);
const dispatch = useAppDispatch();
@@ -418,9 +418,14 @@ const StepAlt: FC<{
async function calculateCroppedImage(
imageSrc: string,
crop: Area,
location: ImageLocation,
): Promise<Blob> {
const image = await dataUriToImage(imageSrc);
const canvas = new OffscreenCanvas(crop.width, crop.height);
const maxWidth = location === 'avatar' ? 400 : 1500;
const scale = Math.min(1, maxWidth / crop.width);
const width = Math.round(crop.width * scale);
const height = Math.round(crop.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
@@ -437,8 +442,8 @@ async function calculateCroppedImage(
crop.height,
0,
0,
crop.width,
crop.height,
width,
height,
);
return canvas.convertToBlob();
@@ -22,34 +22,50 @@ type LegacyEmoji =
};
// Replicates the old legacy search function.
export async function emojiMartSearch(
token: string,
locale: string,
export async function emojiMartSearch({
token,
locale,
limit = 5,
): Promise<LegacyEmoji[]> {
const query = token.replace(':', '').trim();
if (!query.length) {
return [];
signal,
}: {
token: string;
locale: string;
limit?: number;
signal?: AbortSignal;
}): Promise<LegacyEmoji[] | null> {
try {
const query = token.replace(':', '').trim();
if (!query.length) {
return [];
}
const cacheKey = `${query}|${locale}|${limit}`;
const cachedResult = searchCache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
const results = await search({
query,
locale,
limit,
signal,
});
const legacyResults = results.map((emoji) =>
'shortcode' in emoji
? ({ id: emoji.shortcode, custom: true } as const)
: {
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
native: emoji.unicode,
},
);
searchCache.set(cacheKey, legacyResults);
return legacyResults;
} catch {
log('aborted search for "%s"', token);
return null;
}
const cacheKey = `${query}|${locale}|${limit}`;
const cachedResult = searchCache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
const results = await search({ query, locale, limit });
const legacyResults = results.map((emoji) =>
'shortcode' in emoji
? ({ id: emoji.shortcode, custom: true } as const)
: {
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
native: emoji.unicode,
},
);
searchCache.set(cacheKey, legacyResults);
return legacyResults;
}
const defaultCategories = [
@@ -56,13 +56,18 @@ export async function search({
query: rawQuery,
locale: localeString,
limit = 0,
signal,
}: {
query: string;
locale: string;
limit?: number;
signal?: AbortSignal;
}) {
log('searching for "%s"', rawQuery);
performance.mark('emoji-search-start');
signal?.throwIfAborted();
// Get the locale, and extract tokens from the query.
const locale = toSupportedLocale(localeString);
const segmenter = localeToSegmenter(locale);
@@ -93,6 +98,7 @@ export async function search({
locale,
i === queryTokens.length - 1,
);
signal?.throwIfAborted();
const resultMap: ScoreMap = new Map();
const checkedSet = new Set<string>();
@@ -121,6 +127,7 @@ export async function search({
}
// Score based on legacy shortcodes, using the higher score if there's a match.
signal?.throwIfAborted();
for (const shortcodeResult of shortcodeResults) {
const emoji =
resultMap.get(shortcodeResult.hexcode) ??
@@ -183,7 +190,9 @@ export async function search({
// If there are no results, try a cursor-based custom emoji search instead.
if (mixedResults.length === 0 || mixedResults.length < limit) {
signal?.throwIfAborted();
const customEmojisFound = await fullCustomSearch(query, allEmojiIds);
signal?.throwIfAborted();
if (customEmojisFound.length > 0) {
log(
'cursor search found %d results for "%s"',
@@ -373,6 +382,8 @@ async function fullCustomSearch(query: string, existing = new Set<string>()) {
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
const chunkSize = 1_000;
const maxIterations = 10;
let index = 0;
let lastKey: string | null = null;
let keys: string[] = [];
do {
@@ -389,6 +400,10 @@ async function fullCustomSearch(query: string, existing = new Set<string>()) {
foundEmojis.add(key);
}
}
index++;
if (index >= maxIterations) {
break;
}
} while (keys.length === chunkSize);
// Next get the full emojis for all matches.
@@ -518,7 +518,7 @@ body > [data-popper-placement] {
}
.autosuggest-account .account__avatar,
.autosuggest-emoji img {
.autosuggest-emoji .emojione {
display: block;
width: 24px;
height: 24px;
+23 -9
View File
@@ -19,6 +19,8 @@ import { updateTimeline } from './timelines';
let fetchComposeSuggestionsAccountsController;
/** @type {AbortController | undefined} */
let fetchComposeSuggestionsTagsController;
/** @type {AbortController | undefined} */
let searchComposeSuggestionsEmojiController;
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
@@ -499,9 +501,8 @@ export function undoUploadCompose(media_id) {
}
export function clearComposeSuggestions() {
if (fetchComposeSuggestionsAccountsController) {
fetchComposeSuggestionsAccountsController.abort();
}
fetchComposeSuggestionsAccountsController?.abort();
searchComposeSuggestionsEmojiController?.abort();
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
};
@@ -534,12 +535,25 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
// Right now we are hard-coding the locale to English since the picker search only supports English.
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
const results = await emojiMartSearch(token, 'en', 5);
dispatch(readyComposeSuggestionsEmojis(token, results));
};
const fetchComposeSuggestionsEmojis = (dispatch, token) => {
dispatch(clearComposeSuggestions());
searchComposeSuggestionsEmojiController = new AbortController();
void emojiMartSearch({
token,
// Right now we are hard-coding the locale to English since the picker search only supports English.
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
locale: 'en',
limit: 5,
signal: searchComposeSuggestionsEmojiController.signal,
}).then((results) => {
if (results) {
dispatch(readyComposeSuggestionsEmojis(token, results));
}
}).finally(() => {
searchComposeSuggestionsEmojiController = undefined;
});
}
const fetchComposeSuggestionsTags = throttle((dispatch, token) => {
if (fetchComposeSuggestionsTagsController) {
@@ -94,12 +94,12 @@ export const ImageUploadModal: FC<
setStep('select');
return;
}
void calculateCroppedImage(imageSrc, crop).then((blob) => {
void calculateCroppedImage(imageSrc, crop, location).then((blob) => {
setImageBlob(blob);
setStep('alt');
});
},
[imageSrc],
[imageSrc, location],
);
const dispatch = useAppDispatch();
@@ -418,9 +418,14 @@ const StepAlt: FC<{
async function calculateCroppedImage(
imageSrc: string,
crop: Area,
location: ImageLocation,
): Promise<Blob> {
const image = await dataUriToImage(imageSrc);
const canvas = new OffscreenCanvas(crop.width, crop.height);
const maxWidth = location === 'avatar' ? 400 : 1500;
const scale = Math.min(1, maxWidth / crop.width);
const width = Math.round(crop.width * scale);
const height = Math.round(crop.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
@@ -437,8 +442,8 @@ async function calculateCroppedImage(
crop.height,
0,
0,
crop.width,
crop.height,
width,
height,
);
return canvas.convertToBlob();
@@ -22,34 +22,50 @@ type LegacyEmoji =
};
// Replicates the old legacy search function.
export async function emojiMartSearch(
token: string,
locale: string,
export async function emojiMartSearch({
token,
locale,
limit = 5,
): Promise<LegacyEmoji[]> {
const query = token.replace(':', '').trim();
if (!query.length) {
return [];
signal,
}: {
token: string;
locale: string;
limit?: number;
signal?: AbortSignal;
}): Promise<LegacyEmoji[] | null> {
try {
const query = token.replace(':', '').trim();
if (!query.length) {
return [];
}
const cacheKey = `${query}|${locale}|${limit}`;
const cachedResult = searchCache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
const results = await search({
query,
locale,
limit,
signal,
});
const legacyResults = results.map((emoji) =>
'shortcode' in emoji
? ({ id: emoji.shortcode, custom: true } as const)
: {
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
native: emoji.unicode,
},
);
searchCache.set(cacheKey, legacyResults);
return legacyResults;
} catch {
log('aborted search for "%s"', token);
return null;
}
const cacheKey = `${query}|${locale}|${limit}`;
const cachedResult = searchCache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
const results = await search({ query, locale, limit });
const legacyResults = results.map((emoji) =>
'shortcode' in emoji
? ({ id: emoji.shortcode, custom: true } as const)
: {
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
native: emoji.unicode,
},
);
searchCache.set(cacheKey, legacyResults);
return legacyResults;
}
const defaultCategories = [
@@ -56,13 +56,18 @@ export async function search({
query: rawQuery,
locale: localeString,
limit = 0,
signal,
}: {
query: string;
locale: string;
limit?: number;
signal?: AbortSignal;
}) {
log('searching for "%s"', rawQuery);
performance.mark('emoji-search-start');
signal?.throwIfAborted();
// Get the locale, and extract tokens from the query.
const locale = toSupportedLocale(localeString);
const segmenter = localeToSegmenter(locale);
@@ -93,6 +98,7 @@ export async function search({
locale,
i === queryTokens.length - 1,
);
signal?.throwIfAborted();
const resultMap: ScoreMap = new Map();
const checkedSet = new Set<string>();
@@ -121,6 +127,7 @@ export async function search({
}
// Score based on legacy shortcodes, using the higher score if there's a match.
signal?.throwIfAborted();
for (const shortcodeResult of shortcodeResults) {
const emoji =
resultMap.get(shortcodeResult.hexcode) ??
@@ -183,7 +190,9 @@ export async function search({
// If there are no results, try a cursor-based custom emoji search instead.
if (mixedResults.length === 0 || mixedResults.length < limit) {
signal?.throwIfAborted();
const customEmojisFound = await fullCustomSearch(query, allEmojiIds);
signal?.throwIfAborted();
if (customEmojisFound.length > 0) {
log(
'cursor search found %d results for "%s"',
@@ -373,6 +382,8 @@ async function fullCustomSearch(query: string, existing = new Set<string>()) {
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
const chunkSize = 1_000;
const maxIterations = 10;
let index = 0;
let lastKey: string | null = null;
let keys: string[] = [];
do {
@@ -389,6 +400,10 @@ async function fullCustomSearch(query: string, existing = new Set<string>()) {
foundEmojis.add(key);
}
}
index++;
if (index >= maxIterations) {
break;
}
} while (keys.length === chunkSize);
// Next get the full emojis for all matches.
@@ -518,7 +518,7 @@ body > [data-popper-placement] {
}
.autosuggest-account .account__avatar,
.autosuggest-emoji img {
.autosuggest-emoji .emojione {
display: block;
width: 24px;
height: 24px;
+1 -1
View File
@@ -404,7 +404,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def fetch_and_verify_quote
return if @quote.nil?
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context'])
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['@context'])
ActivityPub::VerifyQuoteService.new.call(@quote, @quote_approval_uri, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id], depth: @options[:depth])
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(PROCESSING_DELAY), @quote.id, @quote_uri, { 'request_id' => @options[:request_id], 'approval_uri' => @quote_approval_uri })
+1 -1
View File
@@ -54,7 +54,7 @@ class Collection < ApplicationRecord
if: :remote?
validates :language, language: { if: :local?, allow_nil: true }
validate :tag_is_usable
validate :items_do_not_exceed_limit
validate :items_do_not_exceed_limit, if: :local?
validate :user_does_not_exceed_limit, on: :create
scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) }
+2 -2
View File
@@ -16,10 +16,10 @@ module Account::Merging
Notification, NotificationPermission, NotificationRequest
],
target_account_id: [
Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountNote
Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountNote,
AccountWarning
],
reference_account_id: [CanonicalEmailBlock],
account_warning_id: [Appeal],
local_account_id: [SeveredRelationship],
remote_account_id: [SeveredRelationship],
quoted_account_id: [Quote],
@@ -17,9 +17,10 @@ class ActivityPub::ProcessFeaturedItemService
return if non_matching_actor_and_approval_uris?
return if non_supported_object_type?
with_redis_lock("collection_item:#{@item_json['id']}") do
@collection_item = existing_item || pre_approved_item || new_item
@collection_item = existing_item || pre_approved_item || new_item
return if @collection_item.nil?
with_redis_lock("collection_item:#{@item_json['id']}") do
@collection_item.position = position unless position.nil?
@collection_item.update!(
uri: @item_json['id'],
@@ -45,6 +46,8 @@ class ActivityPub::ProcessFeaturedItemService
end
def new_item
return if @collection.collection_items.count >= ActivityPub::ProcessFeaturedCollectionService::ITEMS_LIMIT
@collection.collection_items.new(
created_at: @item_json['published']
)
@@ -67,7 +70,7 @@ class ActivityPub::ProcessFeaturedItemService
return false if Account.exists?(uri: @actor_uri)
object_json = fetch_resource(@actor_uri, true, raise_on_error: :temporary)
object_json.nil? || (as_array(object_json['fetch']) & ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES).empty?
object_json.nil? || (as_array(object_json['type']) & ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES).empty?
end
def verify_authorization!
@@ -92,7 +92,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
as_array(@json['attachment']).each do |attachment|
media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > Status::MEDIA_ATTACHMENTS_LIMIT
next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size >= Status::MEDIA_ATTACHMENTS_LIMIT
begin
media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }
@@ -377,7 +377,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
def fetch_and_verify_quote!(quote, approval_uri, quote_uri)
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['context'])
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['@context'])
ActivityPub::VerifyQuoteService.new.call(quote, approval_uri, fetchable_quoted_uri: quote_uri, prefetched_quoted_object: embedded_quote, request_id: @request_id)
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(PROCESSING_DELAY), quote.id, quote_uri, { 'request_id' => @request_id, 'approval_uri' => approval_uri })
+3 -3
View File
@@ -60,7 +60,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: ghcr.io/glitch-soc/mastodon:v4.6.4
image: ghcr.io/glitch-soc/mastodon:v4.6.5
restart: always
env_file: .env.production
command: bundle exec puma -C config/puma.rb
@@ -84,7 +84,7 @@ services:
# build:
# dockerfile: ./streaming/Dockerfile
# context: .
image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.4
image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.5
restart: always
env_file: .env.production
command: node ./streaming/index.js
@@ -103,7 +103,7 @@ services:
sidekiq:
# 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: ghcr.io/glitch-soc/mastodon:v4.6.4
image: ghcr.io/glitch-soc/mastodon:v4.6.5
restart: always
env_file: .env.production
command: bundle exec sidekiq
+1 -1
View File
@@ -13,7 +13,7 @@ module Mastodon
end
def patch
4
5
end
def default_prerelease
+18 -10
View File
@@ -53,20 +53,28 @@ RSpec.describe Collection do
end
context 'when there are more items than allowed' do
subject { Fabricate.build(:collection, collection_items:) }
let(:collection_items) { Fabricate.build_times(described_class::MAX_ITEMS + 1, :collection_item, collection: nil) }
it { is_expected.to_not be_valid }
context 'when collection is local' do
subject { Fabricate.build(:collection, collection_items:) }
context 'when the limit is only exceeded due to `rejected` and `revoked` items' do
let(:collection_items) do
items = Fabricate.build_times(described_class::MAX_ITEMS - 2, :collection_item, collection: nil, state: :accepted)
items << Fabricate.build(:collection_item, collection: nil, state: :pending)
items << Fabricate.build(:collection_item, collection: nil, state: :rejected)
items << Fabricate.build(:collection_item, collection: nil, state: :revoked)
items
it { is_expected.to_not be_valid }
context 'when the limit is only exceeded due to `rejected` and `revoked` items' do
let(:collection_items) do
items = Fabricate.build_times(described_class::MAX_ITEMS - 2, :collection_item, collection: nil, state: :accepted)
items << Fabricate.build(:collection_item, collection: nil, state: :pending)
items << Fabricate.build(:collection_item, collection: nil, state: :rejected)
items << Fabricate.build(:collection_item, collection: nil, state: :revoked)
items
end
it { is_expected.to be_valid }
end
end
context 'when collection is remote' do
subject { Fabricate.build(:remote_collection, collection_items:) }
it { is_expected.to be_valid }
end
@@ -81,6 +81,17 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
expect(new_item.position).to eq 1
end
end
context "when the collection's item count is already at the limit" do
before do
stub_const('ActivityPub::ProcessFeaturedCollectionService::ITEMS_LIMIT', 3)
Fabricate.times(3, :collection_item, collection:)
end
it 'does not create an item' do
expect { subject.call(collection, object) }.to_not change(collection.collection_items, :count)
end
end
end
context 'when item exists' do