Merge pull request #996 from kmycode/kb-draft-17.4

Release: 17.4
This commit is contained in:
KMY(雪あすか) 2025-04-03 17:39:06 +09:00 committed by GitHub
commit 178b89615c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 376 additions and 106 deletions

View file

@ -2,6 +2,29 @@
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.3.7] - 2025-04-02
### Add
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)
### Changed
- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire)
- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski)
- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire)
### Fixed
- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire)
- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire)
- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire)
- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire)
- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire)
- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire)
- Fix “x” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire)
## [4.3.6] - 2025-03-13 ## [4.3.6] - 2025-03-13
### Security ### Security

View file

@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, account_params, raise_error: true) UpdateAccountService.new.call(@account, account_params, raise_error: true)
current_user.update(user_params) if user_params current_user.update(user_params) if user_params
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render json: ValidationErrorFormatter.new(e).as_json, status: 422 render json: ValidationErrorFormatter.new(e).as_json, status: 422

View file

@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController
def destroy def destroy
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
end end
end end

View file

@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController
def destroy def destroy
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) UpdateAccountService.new.call(@account, { header: nil }, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
end end
end end

View file

@ -9,13 +9,15 @@ class BackupsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_backup before_action :set_backup
BACKUP_LINK_TIMEOUT = 1.hour.freeze
def download def download
case Paperclip::Attachment.default_options[:storage] case Paperclip::Attachment.default_options[:storage]
when :s3, :azure when :s3, :azure
redirect_to @backup.dump.expiring_url(10), allow_other_host: true redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true
when :fog when :fog
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true
else else
redirect_to full_asset_url(@backup.dump.url), allow_other_host: true redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
end end

View file

@ -8,7 +8,7 @@ module Settings
def destroy def destroy
if valid_picture? if valid_picture?
if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303
else else
redirect_to settings_profile_path redirect_to settings_profile_path

View file

@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params.except(:settings)) if UpdateAccountService.new.call(@account, account_params.except(:settings))
current_user.update!(settings_attributes: account_params[:settings]) current_user.update!(settings_attributes: account_params[:settings])
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg')
else else
render :show render :show

View file

@ -8,7 +8,7 @@ class Settings::PrivacyExtraController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params.except(:settings)) if UpdateAccountService.new.call(@account, account_params.except(:settings))
current_user.update!(settings_attributes: account_params[:settings]) current_user.update!(settings_attributes: account_params[:settings])
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_privacy_extra_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_privacy_extra_path, notice: I18n.t('generic.changes_saved_msg')
else else
render :show render :show

View file

@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params) if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
else else
@account.build_fields @account.build_fields

View file

@ -8,7 +8,7 @@ class Settings::VerificationsController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params) if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg')
else else
render :show render :show

View file

@ -2,11 +2,18 @@
module Admin::Trends::StatusesHelper module Admin::Trends::StatusesHelper
def one_line_preview(status) def one_line_preview(status)
text = if status.local? text = begin
status.text.split("\n").first if status.local?
else status.text.split("\n").first
Nokogiri::HTML5(status.text).css('html > body > *').first&.text else
end Nokogiri::HTML5(status.text).css('html > body > *').first&.text
end
rescue ArgumentError
# This can happen if one of the Nokogumbo limits is encountered
# Unfortunately, it does not use a more precise error class
# nor allows more graceful handling
''
end
return '' if text.blank? return '' if text.blank?

View file

@ -101,6 +101,7 @@ class Bookmarks extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
timelineId='bookmarks'
/> />
<Helmet> <Helmet>

View file

@ -101,6 +101,7 @@ class Favourites extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
timelineId='favourites'
/> />
<Helmet> <Helmet>

View file

@ -15,6 +15,7 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
import { AnimatedNumber } from 'mastodon/components/animated_number'; import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp'; import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { FilterWarning } from 'mastodon/components/filter_warning';
import type { StatusLike } from 'mastodon/components/hashtag_bar'; import type { StatusLike } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar'; import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
@ -80,6 +81,7 @@ export const DetailedStatus: React.FC<{
}) => { }) => {
const properStatus = status?.get('reblog') ?? status; const properStatus = status?.get('reblog') ?? status;
const [height, setHeight] = useState(0); const [height, setHeight] = useState(0);
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
const nodeRef = useRef<HTMLDivElement>(); const nodeRef = useRef<HTMLDivElement>();
const handleOpenVideo = useCallback( const handleOpenVideo = useCallback(
@ -92,6 +94,10 @@ export const DetailedStatus: React.FC<{
[onOpenVideo, status], [onOpenVideo, status],
); );
const handleFilterToggle = useCallback(() => {
setShowDespiteFilter(!showDespiteFilter);
}, [showDespiteFilter, setShowDespiteFilter]);
const handleExpandedToggle = useCallback(() => { const handleExpandedToggle = useCallback(() => {
if (onToggleHidden) onToggleHidden(status); if (onToggleHidden) onToggleHidden(status);
}, [onToggleHidden, status]); }, [onToggleHidden, status]);
@ -369,8 +375,12 @@ export const DetailedStatus: React.FC<{
const { statusContentProps, hashtagBar } = getHashtagBarForStatus( const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
status as StatusLike, status as StatusLike,
); );
const matchedFilters = status.get('matched_filters');
const expanded = const expanded =
!status.get('hidden') || status.get('spoiler_text').length === 0; (!matchedFilters || showDespiteFilter) &&
(!status.get('hidden') || status.get('spoiler_text').length === 0);
const quote = !muted && status.get('quote_id') && ( const quote = !muted && status.get('quote_id') && (
<> <>
@ -418,17 +428,26 @@ export const DetailedStatus: React.FC<{
)} )}
</Link> </Link>
{status.get('spoiler_text').length > 0 && ( {matchedFilters && (
<ContentWarning <FilterWarning
text={ title={matchedFilters.join(', ')}
status.getIn(['translation', 'spoilerHtml']) || expanded={showDespiteFilter}
status.get('spoilerHtml') onClick={handleFilterToggle}
}
expanded={expanded}
onClick={handleExpandedToggle}
/> />
)} )}
{status.get('spoiler_text').length > 0 &&
(!matchedFilters || showDespiteFilter) && (
<ContentWarning
text={
status.getIn(['translation', 'spoilerHtml']) ||
status.get('spoilerHtml')
}
expanded={expanded}
onClick={handleExpandedToggle}
/>
)}
{expanded && ( {expanded && (
<> <>
<StatusContent <StatusContent

View file

@ -147,7 +147,7 @@ const makeMapStateToProps = () => {
}); });
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId }); const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
let ancestorsIds = ImmutableList(); let ancestorsIds = ImmutableList();
let descendantsIds = ImmutableList(); let descendantsIds = ImmutableList();

View file

@ -17,9 +17,10 @@ export const makeGetStatus = () => {
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
getFilters, getFilters,
(_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType),
], ],
(statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters) => { (statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters, warnInsteadOfHide) => {
if (!statusBase || statusBase.get('isLoading')) { if (!statusBase || statusBase.get('isLoading')) {
return null; return null;
} }
@ -46,7 +47,7 @@ export const makeGetStatus = () => {
} }
} }
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { if (!warnInsteadOfHide && filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
return null; return null;
} }
filterResults = filterResults.filter(result => filters.has(result.get('filter'))); filterResults = filterResults.filter(result => filters.has(result.get('filter')));

View file

@ -7,6 +7,11 @@ export const toServerSideType = (columnType: string) => {
case 'account': case 'account':
case 'explore': case 'explore':
return columnType; return columnType;
case 'detailed':
return 'thread';
case 'bookmarks':
case 'favourites':
return 'home';
default: default:
if (columnType.includes('list:') || columnType.includes('antenna:')) { if (columnType.includes('list:') || columnType.includes('antenna:')) {
return 'home'; return 'home';

View file

@ -10,7 +10,7 @@ class AccountReachFinder
end end
def inboxes def inboxes
(followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + relay_inboxes).uniq (followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + recently_followed_inboxes + recently_requested_inboxes + relay_inboxes).uniq
end end
private private
@ -31,13 +31,32 @@ class AccountReachFinder
.take(RECENT_LIMIT) .take(RECENT_LIMIT)
end end
def recently_followed_inboxes
@account
.following
.where(follows: { created_at: recent_date_cutoff... })
.inboxes
.take(RECENT_LIMIT)
end
def recently_requested_inboxes
Account
.where(id: @account.follow_requests.where({ created_at: recent_date_cutoff... }).select(:target_account_id))
.inboxes
.take(RECENT_LIMIT)
end
def relay_inboxes def relay_inboxes
Relay.enabled.pluck(:inbox_url) Relay.enabled.pluck(:inbox_url)
end end
def oldest_status_id def oldest_status_id
Mastodon::Snowflake Mastodon::Snowflake
.id_at(STATUS_SINCE.ago, with_random: false) .id_at(recent_date_cutoff, with_random: false)
end
def recent_date_cutoff
@account.suspended? && @account.suspension_origin_local? ? @account.suspended_at - STATUS_SINCE : STATUS_SINCE.ago
end end
def recent_statuses def recent_statuses

View file

@ -24,7 +24,15 @@ class EmojiFormatter
def to_s def to_s
return html if custom_emojis.empty? || html.blank? return html if custom_emojis.empty? || html.blank?
tree = Nokogiri::HTML5.fragment(html) begin
tree = Nokogiri::HTML5.fragment(html)
rescue ArgumentError
# This can happen if one of the Nokogumbo limits is encountered
# Unfortunately, it does not use a more precise error class
# nor allows more graceful handling
return ''
end
tree.xpath('./text()|.//text()[not(ancestor[@class="invisible"])]').to_a.each do |node| tree.xpath('./text()|.//text()[not(ancestor[@class="invisible"])]').to_a.each do |node|
i = -1 i = -1
inside_shortname = false inside_shortname = false

View file

@ -16,7 +16,15 @@ class PlainTextFormatter
if local? if local?
text text
else else
node = Nokogiri::HTML5.fragment(insert_newlines) begin
node = Nokogiri::HTML5.fragment(insert_newlines)
rescue ArgumentError
# This can happen if one of the Nokogumbo limits is encountered
# Unfortunately, it does not use a more precise error class
# nor allows more graceful handling
return ''
end
# Elements that are entirely removed with our Sanitize config # Elements that are entirely removed with our Sanitize config
node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove
node.text.chomp node.text.chomp

View file

@ -160,7 +160,7 @@ class Account < ApplicationRecord
scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
scope :dormant, -> { joins(:account_stat).merge(AccountStat.without_recent_activity) } scope :dormant, -> { joins(:account_stat).merge(AccountStat.without_recent_activity) }
scope :with_username, ->(value) { where arel_table[:username].lower.eq(value.to_s.downcase) } scope :with_username, ->(value) { value.is_a?(Array) ? where(arel_table[:username].lower.in(value.map { |x| x.to_s.downcase })) : where(arel_table[:username].lower.eq(value.to_s.downcase)) }
scope :with_domain, ->(value) { where arel_table[:domain].lower.eq(value&.to_s&.downcase) } scope :with_domain, ->(value) { where arel_table[:domain].lower.eq(value&.to_s&.downcase) }
scope :without_memorial, -> { where(memorial: false) } scope :without_memorial, -> { where(memorial: false) }
scope :duplicate_uris, -> { select(:uri, Arel.star.count).group(:uri).having(Arel.star.count.gt(1)) } scope :duplicate_uris, -> { select(:uri, Arel.star.count).group(:uri).having(Arel.star.count.gt(1)) }

View file

@ -73,7 +73,14 @@ class Account::Field < ActiveModelSerializers::Model
end end
def extract_url_from_html def extract_url_from_html
doc = Nokogiri::HTML5.fragment(value) begin
doc = Nokogiri::HTML5.fragment(value)
rescue ArgumentError
# This can happen if one of the Nokogumbo limits is encountered
# Unfortunately, it does not use a more precise error class
# nor allows more graceful handling
return
end
return if doc.nil? return if doc.nil?
return if doc.children.size != 1 return if doc.children.size != 1

View file

@ -425,8 +425,10 @@ class MediaAttachment < ApplicationRecord
@paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name| @paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name|
attachment = public_send(attachment_name) attachment = public_send(attachment_name)
next if attachment.blank?
styles = DEFAULT_STYLES | attachment.styles.keys styles = DEFAULT_STYLES | attachment.styles.keys
styles.map { |style| attachment.path(style) } styles.map { |style| attachment.url(style) }
end.compact end.compact
rescue => e rescue => e
# We really don't want any error here preventing media deletion # We really don't want any error here preventing media deletion

View file

@ -4,32 +4,46 @@ class ActivityPub::SynchronizeFollowersService < BaseService
include JsonLdHelper include JsonLdHelper
include Payloadable include Payloadable
MAX_COLLECTION_PAGES = 10
def call(account, partial_collection_url) def call(account, partial_collection_url)
@account = account @account = account
@expected_followers_ids = []
items = collection_items(partial_collection_url) return unless process_collection!(partial_collection_url)
return if items.nil?
# There could be unresolved accounts (hence the call to .compact) but this
# should never happen in practice, since in almost all cases we keep an
# Account record, and should we not do that, we should have sent a Delete.
# In any case there is not much we can do if that occurs.
@expected_followers = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) }
remove_unexpected_local_followers! remove_unexpected_local_followers!
handle_unexpected_outgoing_follows!
end end
private private
def process_page!(items)
page_expected_followers = extract_local_followers(items)
@expected_followers_ids.concat(page_expected_followers.pluck(:id))
handle_unexpected_outgoing_follows!(page_expected_followers)
end
def extract_local_followers(items)
# There could be unresolved accounts (hence the call to .filter_map) but this
# should never happen in practice, since in almost all cases we keep an
# Account record, and should we not do that, we should have sent a Delete.
# In any case there is not much we can do if that occurs.
# TODO: this will need changes when switching to numeric IDs
usernames = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username)&.downcase }
Account.local.with_username(usernames)
end
def remove_unexpected_local_followers! def remove_unexpected_local_followers!
@account.followers.local.where.not(id: @expected_followers.map(&:id)).reorder(nil).find_each do |unexpected_follower| @account.followers.local.where.not(id: @expected_followers_ids).reorder(nil).find_each do |unexpected_follower|
UnfollowService.new.call(unexpected_follower, @account) UnfollowService.new.call(unexpected_follower, @account)
end end
end end
def handle_unexpected_outgoing_follows! def handle_unexpected_outgoing_follows!(expected_followers)
@expected_followers.each do |expected_follower| expected_followers.each do |expected_follower|
next if expected_follower.following?(@account) next if expected_follower.following?(@account)
if expected_follower.requested?(@account) if expected_follower.requested?(@account)
@ -50,18 +64,33 @@ class ActivityPub::SynchronizeFollowersService < BaseService
Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)) Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer))
end end
def collection_items(collection_or_uri) # Only returns true if the whole collection has been processed
collection = fetch_collection(collection_or_uri) def process_collection!(collection_uri, max_pages: MAX_COLLECTION_PAGES)
return unless collection.is_a?(Hash) collection = fetch_collection(collection_uri)
return false unless collection.is_a?(Hash)
collection = fetch_collection(collection['first']) if collection['first'].present? collection = fetch_collection(collection['first']) if collection['first'].present?
return unless collection.is_a?(Hash)
while collection.is_a?(Hash)
process_page!(as_array(collection_page_items(collection)))
max_pages -= 1
return true if collection['next'].blank? # We reached the end of the collection
return false if max_pages <= 0 # We reached our pages limit
collection = fetch_collection(collection['next'])
end
false
end
def collection_page_items(collection)
case collection['type'] case collection['type']
when 'Collection', 'CollectionPage' when 'Collection', 'CollectionPage'
as_array(collection['items']) collection['items']
when 'OrderedCollection', 'OrderedCollectionPage' when 'OrderedCollection', 'OrderedCollectionPage'
as_array(collection['orderedItems']) collection['orderedItems']
end end
end end

View file

@ -95,7 +95,7 @@ class SuspendAccountService < BaseService
end end
end end
CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled
end end
end end
end end

View file

@ -91,7 +91,7 @@ class UnsuspendAccountService < BaseService
end end
end end
CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled
end end
end end
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker
DEBOUNCE_DELAY = 5.seconds
sidekiq_options queue: 'push', lock: :until_executed, lock_ttl: 1.day.to_i sidekiq_options queue: 'push', lock: :until_executed, lock_ttl: 1.day.to_i
# Distribute an profile update to servers that might have a copy # Distribute an profile update to servers that might have a copy

View file

@ -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: kmyblue:17.3 image: kmyblue:17.4
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: kmyblue-streaming:17.3 image: kmyblue-streaming:17.4
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming/index.js command: node ./streaming/index.js
@ -101,7 +101,7 @@ services:
sidekiq: sidekiq:
build: . build: .
image: kmyblue:17.3 image: kmyblue:17.4
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View file

@ -13,7 +13,7 @@ module Mastodon
end end
def kmyblue_minor def kmyblue_minor
3 4
end end
def kmyblue_flag def kmyblue_flag

View file

@ -123,7 +123,14 @@ module Paperclip
end end
def needs_convert? def needs_convert?
needs_different_geometry? || needs_different_format? || needs_metadata_stripping? strip_animations? || needs_different_geometry? || needs_different_format? || needs_metadata_stripping?
end
def strip_animations?
# Detecting whether the source image is animated across all our supported
# input file formats is not trivial, and converting unconditionally is just
# as simple for now
options[:style] == :static
end end
def needs_different_geometry? def needs_different_geometry?

View file

@ -31,7 +31,7 @@ RSpec.describe Settings::PrivacyController do
describe 'PUT #update' do describe 'PUT #update' do
context 'when update succeeds' do context 'when update succeeds' do
before do before do
allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in)
end end
it 'updates the user profile' do it 'updates the user profile' do
@ -44,14 +44,14 @@ RSpec.describe Settings::PrivacyController do
.to redirect_to(settings_privacy_path) .to redirect_to(settings_privacy_path)
expect(ActivityPub::UpdateDistributionWorker) expect(ActivityPub::UpdateDistributionWorker)
.to have_received(:perform_async).with(account.id) .to have_received(:perform_in).with(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, account.id)
end end
end end
context 'when update fails' do context 'when update fails' do
before do before do
allow(UpdateAccountService).to receive(:new).and_return(failing_update_service) allow(UpdateAccountService).to receive(:new).and_return(failing_update_service)
allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in)
end end
it 'updates the user profile' do it 'updates the user profile' do
@ -61,7 +61,7 @@ RSpec.describe Settings::PrivacyController do
.to render_template(:show) .to render_template(:show)
expect(ActivityPub::UpdateDistributionWorker) expect(ActivityPub::UpdateDistributionWorker)
.to_not have_received(:perform_async) .to_not have_received(:perform_in)
end end
private private

View file

@ -13,13 +13,28 @@ RSpec.describe AccountReachFinder do
let(:ap_mentioned_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3', domain: 'example.com') } let(:ap_mentioned_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3', domain: 'example.com') }
let(:ap_mentioned_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-4', domain: 'example.org') } let(:ap_mentioned_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-4', domain: 'example.org') }
let(:ap_followed_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-5', domain: 'example.com') }
let(:ap_followed_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-6', domain: 'example.org') }
let(:ap_requested_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-7', domain: 'example.com') }
let(:ap_requested_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-8', domain: 'example.org') }
let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox', domain: 'example.com') } let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox', domain: 'example.com') }
let(:old_followed_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/old-followed-inbox', domain: 'example.com') }
before do before do
travel_to(2.months.ago) { account.follow!(old_followed_account) }
ap_follower_example_com.follow!(account) ap_follower_example_com.follow!(account)
ap_follower_example_org.follow!(account) ap_follower_example_org.follow!(account)
ap_follower_with_shared.follow!(account) ap_follower_with_shared.follow!(account)
account.follow!(ap_followed_example_com)
account.follow!(ap_followed_example_org)
account.request_follow!(ap_requested_example_com)
account.request_follow!(ap_requested_example_org)
Fabricate(:status, account: account).tap do |status| Fabricate(:status, account: account).tap do |status|
status.mentions << Mention.new(account: ap_follower_example_com) status.mentions << Mention.new(account: ap_follower_example_com)
status.mentions << Mention.new(account: ap_mentioned_with_shared) status.mentions << Mention.new(account: ap_mentioned_with_shared)
@ -44,7 +59,10 @@ RSpec.describe AccountReachFinder do
expect(subject) expect(subject)
.to include(*follower_inbox_urls) .to include(*follower_inbox_urls)
.and include(*mentioned_account_inbox_urls) .and include(*mentioned_account_inbox_urls)
.and include(*recently_followed_inbox_urls)
.and include(*recently_requested_inbox_urls)
.and not_include(unrelated_account.preferred_inbox_url) .and not_include(unrelated_account.preferred_inbox_url)
.and not_include(old_followed_account.preferred_inbox_url)
end end
def follower_inbox_urls def follower_inbox_urls
@ -56,5 +74,15 @@ RSpec.describe AccountReachFinder do
[ap_mentioned_with_shared, ap_mentioned_example_com, ap_mentioned_example_org] [ap_mentioned_with_shared, ap_mentioned_example_com, ap_mentioned_example_org]
.map(&:preferred_inbox_url) .map(&:preferred_inbox_url)
end end
def recently_followed_inbox_urls
[ap_followed_example_com, ap_followed_example_org]
.map(&:preferred_inbox_url)
end
def recently_requested_inbox_urls
[ap_requested_example_com, ap_requested_example_org]
.map(&:preferred_inbox_url)
end
end end
end end

View file

@ -295,12 +295,21 @@ RSpec.describe MediaAttachment, :attachment_processing do
end end
it 'queues CacheBusterWorker jobs' do it 'queues CacheBusterWorker jobs' do
original_path = media.file.path(:original) original_url = media.file.url(:original)
small_path = media.file.path(:small) small_url = media.file.url(:small)
expect { media.destroy } expect { media.destroy }
.to enqueue_sidekiq_job(CacheBusterWorker).with(original_path) .to enqueue_sidekiq_job(CacheBusterWorker).with(original_url)
.and enqueue_sidekiq_job(CacheBusterWorker).with(small_path) .and enqueue_sidekiq_job(CacheBusterWorker).with(small_url)
end
context 'with a missing remote attachment' do
let(:media) { Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png', file: nil) }
it 'does not queue CacheBusterWorker jobs' do
expect { media.destroy }
.to_not enqueue_sidekiq_job(CacheBusterWorker)
end
end end
end end

View file

@ -53,8 +53,6 @@ RSpec.describe 'credentials API' do
patch '/api/v1/accounts/update_credentials', headers: headers, params: params patch '/api/v1/accounts/update_credentials', headers: headers, params: params
end end
before { allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) }
let(:params) do let(:params) do
{ {
avatar: fixture_file_upload('avatar.gif', 'image/gif'), avatar: fixture_file_upload('avatar.gif', 'image/gif'),
@ -113,7 +111,7 @@ RSpec.describe 'credentials API' do
}) })
expect(ActivityPub::UpdateDistributionWorker) expect(ActivityPub::UpdateDistributionWorker)
.to have_received(:perform_async).with(user.account_id) .to have_enqueued_sidekiq_job(user.account_id)
end end
def expect_account_updates def expect_account_updates

View file

@ -15,10 +15,6 @@ RSpec.describe 'Deleting profile images' do
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'DELETE /api/v1/profile' do describe 'DELETE /api/v1/profile' do
before do
allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async)
end
context 'when deleting an avatar' do context 'when deleting an avatar' do
context 'with wrong scope' do context 'with wrong scope' do
before do before do
@ -38,7 +34,8 @@ RSpec.describe 'Deleting profile images' do
account.reload account.reload
expect(account.avatar).to_not exist expect(account.avatar).to_not exist
expect(account.header).to exist expect(account.header).to exist
expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) expect(ActivityPub::UpdateDistributionWorker)
.to have_enqueued_sidekiq_job(account.id)
end end
end end
@ -61,7 +58,8 @@ RSpec.describe 'Deleting profile images' do
account.reload account.reload
expect(account.avatar).to exist expect(account.avatar).to exist
expect(account.header).to_not exist expect(account.header).to_not exist
expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) expect(ActivityPub::UpdateDistributionWorker)
.to have_enqueued_sidekiq_job(account.id)
end end
end end
end end

View file

@ -170,7 +170,7 @@ RSpec.describe 'Notifications' do
end end
context 'with min_id param' do context 'with min_id param' do
let(:params) { { min_id: user.account.notifications.reload.first.id - 1 } } let(:params) { { min_id: user.account.notifications.order(id: :asc).first.id - 1 } }
it 'returns a notification group covering all notifications' do it 'returns a notification group covering all notifications' do
subject subject

View file

@ -10,7 +10,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
let(:bob) { Fabricate(:account, username: 'bob') } let(:bob) { Fabricate(:account, username: 'bob') }
let(:eve) { Fabricate(:account, username: 'eve') } let(:eve) { Fabricate(:account, username: 'eve') }
let(:mallory) { Fabricate(:account, username: 'mallory') } let(:mallory) { Fabricate(:account, username: 'mallory') }
let(:collection_uri) { 'http://example.com/partial-followers' } let(:collection_uri) { 'https://example.com/partial-followers' }
let(:items) do let(:items) do
[alice, eve, mallory].map do |account| [alice, eve, mallory].map do |account|
@ -27,14 +27,14 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
}.with_indifferent_access }.with_indifferent_access
end end
before do
alice.follow!(actor)
bob.follow!(actor)
mallory.request_follow!(actor)
end
shared_examples 'synchronizes followers' do shared_examples 'synchronizes followers' do
before do before do
alice.follow!(actor)
bob.follow!(actor)
mallory.request_follow!(actor)
allow(ActivityPub::DeliveryWorker).to receive(:perform_async)
subject.call(actor, collection_uri) subject.call(actor, collection_uri)
end end
@ -46,7 +46,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
expect(mallory) expect(mallory)
.to be_following(actor) # Convert follow request to follow when accepted .to be_following(actor) # Convert follow request to follow when accepted
expect(ActivityPub::DeliveryWorker) expect(ActivityPub::DeliveryWorker)
.to have_received(:perform_async).with(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor .to have_enqueued_sidekiq_job(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor
end end
end end
@ -76,7 +76,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
it_behaves_like 'synchronizes followers' it_behaves_like 'synchronizes followers'
end end
context 'when the endpoint is a paginated Collection of actor URIs' do context 'when the endpoint is a single-page paginated Collection of actor URIs' do
let(:payload) do let(:payload) do
{ {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
@ -96,5 +96,106 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
it_behaves_like 'synchronizes followers' it_behaves_like 'synchronizes followers'
end end
context 'when the endpoint is a paginated Collection of actor URIs split across multiple pages' do
before do
stub_request(:get, 'https://example.com/partial-followers')
.to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: 'https://example.com/partial-followers',
first: 'https://example.com/partial-followers/1',
}))
stub_request(:get, 'https://example.com/partial-followers/1')
.to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'CollectionPage',
id: 'https://example.com/partial-followers/1',
partOf: 'https://example.com/partial-followers',
next: 'https://example.com/partial-followers/2',
items: [alice, eve].map { |account| ActivityPub::TagManager.instance.uri_for(account) },
}))
stub_request(:get, 'https://example.com/partial-followers/2')
.to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'CollectionPage',
id: 'https://example.com/partial-followers/2',
partOf: 'https://example.com/partial-followers',
items: ActivityPub::TagManager.instance.uri_for(mallory),
}))
end
it_behaves_like 'synchronizes followers'
end
context 'when the endpoint is a paginated Collection of actor URIs split across, but one page errors out' do
before do
stub_request(:get, 'https://example.com/partial-followers')
.to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: 'https://example.com/partial-followers',
first: 'https://example.com/partial-followers/1',
}))
stub_request(:get, 'https://example.com/partial-followers/1')
.to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'CollectionPage',
id: 'https://example.com/partial-followers/1',
partOf: 'https://example.com/partial-followers',
next: 'https://example.com/partial-followers/2',
items: [mallory].map { |account| ActivityPub::TagManager.instance.uri_for(account) },
}))
stub_request(:get, 'https://example.com/partial-followers/2')
.to_return(status: 404)
end
it 'confirms pending follow request but does not remove extra followers' do
previous_follower_ids = actor.followers.pluck(:id)
subject.call(actor, collection_uri)
expect(previous_follower_ids - actor.followers.reload.pluck(:id))
.to be_empty
expect(mallory)
.to be_following(actor)
end
end
context 'when the endpoint is a paginated Collection of actor URIs with more pages than we allow' do
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: collection_uri,
first: {
type: 'CollectionPage',
partOf: collection_uri,
items: items,
next: "#{collection_uri}/page2",
},
}.with_indifferent_access
end
before do
stub_const('ActivityPub::SynchronizeFollowersService::MAX_COLLECTION_PAGES', 1)
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
end
it 'confirms pending follow request but does not remove extra followers' do
previous_follower_ids = actor.followers.pluck(:id)
subject.call(actor, collection_uri)
expect(previous_follower_ids - actor.followers.reload.pluck(:id))
.to be_empty
expect(mallory)
.to be_following(actor)
end
end
end end
end end

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe SuspendAccountService, :inline_jobs do RSpec.describe SuspendAccountService do
shared_examples 'common behavior' do shared_examples 'common behavior' do
subject { described_class.new.call(account) } subject { described_class.new.call(account) }
@ -11,6 +11,7 @@ RSpec.describe SuspendAccountService, :inline_jobs do
before do before do
allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil) allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil)
allow(Rails.configuration.x).to receive(:cache_buster_enabled).and_return(true)
local_follower.follow!(account) local_follower.follow!(account)
list.accounts << account list.accounts << account
@ -23,6 +24,7 @@ RSpec.describe SuspendAccountService, :inline_jobs do
it 'unmerges from feeds of local followers and changes file mode and preserves suspended flag' do it 'unmerges from feeds of local followers and changes file mode and preserves suspended flag' do
expect { subject } expect { subject }
.to change_file_mode .to change_file_mode
.and enqueue_sidekiq_job(CacheBusterWorker).with(account.media_attachments.first.file.url(:original))
.and not_change_suspended_flag .and not_change_suspended_flag
expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower) expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower)
expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list) expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list)
@ -38,17 +40,12 @@ RSpec.describe SuspendAccountService, :inline_jobs do
end end
describe 'suspending a local account' do describe 'suspending a local account' do
def match_update_actor_request(req, account) def match_update_actor_request(json, account)
json = JSON.parse(req.body) json = JSON.parse(json)
actor_id = ActivityPub::TagManager.instance.uri_for(account) actor_id = ActivityPub::TagManager.instance.uri_for(account)
json['type'] == 'Update' && json['actor'] == actor_id && json['object']['id'] == actor_id && json['object']['suspended'] json['type'] == 'Update' && json['actor'] == actor_id && json['object']['id'] == actor_id && json['object']['suspended']
end end
before do
stub_request(:post, 'https://alice.com/inbox').to_return(status: 201)
stub_request(:post, 'https://bob.com/inbox').to_return(status: 201)
end
include_examples 'common behavior' do include_examples 'common behavior' do
let!(:account) { Fabricate(:account) } let!(:account) { Fabricate(:account) }
let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') } let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') }
@ -61,22 +58,20 @@ RSpec.describe SuspendAccountService, :inline_jobs do
it 'sends an Update actor activity to followers and reporters' do it 'sends an Update actor activity to followers and reporters' do
subject subject
expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once expect(ActivityPub::DeliveryWorker)
.to have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_follower.inbox_url).once
.and have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_reporter.inbox_url).once
end end
end end
end end
describe 'suspending a remote account' do describe 'suspending a remote account' do
def match_reject_follow_request(req, account, followee) def match_reject_follow_request(json, account, followee)
json = JSON.parse(req.body) json = JSON.parse(json)
json['type'] == 'Reject' && json['actor'] == ActivityPub::TagManager.instance.uri_for(followee) && json['object']['actor'] == account.uri json['type'] == 'Reject' && json['actor'] == ActivityPub::TagManager.instance.uri_for(followee) && json['object']['actor'] == account.uri
end end
before do
stub_request(:post, 'https://bob.com/inbox').to_return(status: 201)
end
include_examples 'common behavior' do include_examples 'common behavior' do
let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
let!(:local_followee) { Fabricate(:account) } let!(:local_followee) { Fabricate(:account) }
@ -88,7 +83,8 @@ RSpec.describe SuspendAccountService, :inline_jobs do
it 'sends a Reject Follow activity', :aggregate_failures do it 'sends a Reject Follow activity', :aggregate_failures do
subject subject
expect(a_request(:post, account.inbox_url).with { |req| match_reject_follow_request(req, account, local_followee) }).to have_been_made.once expect(ActivityPub::DeliveryWorker)
.to have_enqueued_sidekiq_job(satisfying { |json| match_reject_follow_request(json, account, local_followee) }, local_followee.id, account.inbox_url).once
end end
end end
end end

View file

@ -7,7 +7,6 @@ RSpec.describe 'Settings profile page' do
let(:account) { user.account } let(:account) { user.account }
before do before do
allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async)
sign_in user sign_in user
end end
@ -24,7 +23,7 @@ RSpec.describe 'Settings profile page' do
.to change { account.reload.display_name }.to('New name') .to change { account.reload.display_name }.to('New name')
.and(change { account.reload.avatar.instance.avatar_file_name }.from(nil).to(be_present)) .and(change { account.reload.avatar.instance.avatar_file_name }.from(nil).to(be_present))
expect(ActivityPub::UpdateDistributionWorker) expect(ActivityPub::UpdateDistributionWorker)
.to have_received(:perform_async).with(account.id) .to have_enqueued_sidekiq_job(account.id)
end end
def display_name_field def display_name_field

View file

@ -49,7 +49,7 @@ export function configFromEnv(env, environment) {
if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password; if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host; if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user; if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
if (typeof parsedUrl.port === 'string') { if (typeof parsedUrl.port === 'string' && parsedUrl.port) {
const parsedPort = parseInt(parsedUrl.port, 10); const parsedPort = parseInt(parsedUrl.port, 10);
if (isNaN(parsedPort)) { if (isNaN(parsedPort)) {
throw new Error('Invalid port specified in DATABASE_URL environment variable'); throw new Error('Invalid port specified in DATABASE_URL environment variable');