Merge commit '420ffdfb6259740602d4ce727ed5c60432c776a5' into glitch-soc/merge-upstream

pull/2993/head
Claire 2025-03-11 18:44:32 +01:00
commit 399fbf97de
28 changed files with 196 additions and 184 deletions

View File

@ -613,7 +613,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.12)
rack (2.2.13)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)

View File

@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
@account = current_account
UpdateAccountService.new.call(@account, account_params, raise_error: true)
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
rescue ActiveRecord::RecordInvalid => e
render json: ValidationErrorFormatter.new(e).as_json, status: 422

View File

@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController
def destroy
@account = current_account
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
end
end

View File

@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController
def destroy
@account = current_account
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
end
end

View File

@ -8,7 +8,7 @@ module Settings
def destroy
if valid_picture?
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
else
redirect_to settings_profile_path

View File

@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController
def update
if UpdateAccountService.new.call(@account, account_params.except(: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')
else
render :show

View File

@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController
def update
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')
else
@account.build_fields

View File

@ -8,7 +8,7 @@ class Settings::VerificationsController < Settings::BaseController
def update
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')
else
render :show

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -28,28 +28,30 @@ export const ActionBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
const handleLogoutClick = useCallback(() => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
}, [dispatch]);
const menu = useMemo(() => {
const handleLogoutClick = () => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
};
let menu = [];
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick });
return ([
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
{ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
null,
{ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' },
{ text: intl.formatMessage(messages.favourites), to: '/favourites' },
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
{ text: intl.formatMessage(messages.lists), to: '/lists' },
{ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' },
null,
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
{ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' },
{ text: intl.formatMessage(messages.filters), href: '/filters' },
null,
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
]);
}, [intl, dispatch]);
return (
<DropdownMenuContainer

View File

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

View File

@ -34,11 +34,11 @@ require_relative '../lib/paperclip/transcoder'
require_relative '../lib/paperclip/type_corrector'
require_relative '../lib/paperclip/response_with_limit_adapter'
require_relative '../lib/terrapin/multi_pipe_extensions'
require_relative '../lib/mastodon/middleware/public_file_server'
require_relative '../lib/mastodon/middleware/socket_cleanup'
require_relative '../lib/mastodon/snowflake'
require_relative '../lib/mastodon/feature'
require_relative '../lib/mastodon/version'
require_relative '../lib/mastodon/rack_middleware'
require_relative '../lib/public_file_server_middleware'
require_relative '../lib/devise/strategies/two_factor_ldap_authenticatable'
require_relative '../lib/devise/strategies/two_factor_pam_authenticatable'
require_relative '../lib/elasticsearch/client_extensions'
@ -88,9 +88,9 @@ module Mastodon
# We use our own middleware for this
config.public_file_server.enabled = false
config.middleware.use PublicFileServerMiddleware if Rails.env.local? || ENV['RAILS_SERVE_STATIC_FILES'] == 'true'
config.middleware.use Mastodon::Middleware::PublicFileServer if Rails.env.local? || ENV['RAILS_SERVE_STATIC_FILES'] == 'true'
config.middleware.use Rack::Attack
config.middleware.use Mastodon::RackMiddleware
config.middleware.use Mastodon::Middleware::SocketCleanup
config.before_configuration do
require 'mastodon/redis_configuration'

View File

@ -10,3 +10,5 @@ shared:
version:
metadata: <%= ['glitch', ENV.fetch('MASTODON_VERSION_METADATA', nil)].compact_blank.join('.') %>
prerelease: <%= ENV.fetch('MASTODON_VERSION_PRERELEASE', nil) %>
test:
experimental_features: <%= [ENV.fetch('EXPERIMENTAL_FEATURES', nil), 'testing_only'].compact.join(',') %>

View File

@ -19,8 +19,8 @@ module Mastodon::Feature
super
end
def respond_to_missing?(name)
name.to_s.end_with?('_enabled?')
def respond_to_missing?(name, include_all = false)
name.to_s.end_with?('_enabled?') || super
end
end
end

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'action_dispatch/middleware/static'
module Mastodon
module Middleware
class PublicFileServer
SERVICE_WORKER_TTL = 7.days.to_i
CACHE_TTL = 28.days.to_i
def initialize(app)
@app = app
@file_handler = ActionDispatch::FileHandler.new(Rails.application.paths['public'].first)
end
def call(env)
file = @file_handler.attempt(env)
# If the request is not a static file, move on!
return @app.call(env) if file.nil?
status, headers, response = file
# Set cache headers on static files. Some paths require different cache headers
headers['Cache-Control'] = begin
request_path = env['REQUEST_PATH']
if request_path.start_with?('/sw.js')
"public, max-age=#{SERVICE_WORKER_TTL}, must-revalidate"
elsif request_path.start_with?(paperclip_root_url)
"public, max-age=#{CACHE_TTL}, immutable"
else
"public, max-age=#{CACHE_TTL}, must-revalidate"
end
end
# Override the default CSP header set by the CSP middleware
headers['Content-Security-Policy'] = "default-src 'none'; form-action 'none'" if request_path.start_with?(paperclip_root_url)
headers['X-Content-Type-Options'] = 'nosniff'
[status, headers, response]
end
private
def paperclip_root_url
ENV.fetch('PAPERCLIP_ROOT_URL', '/system')
end
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
module Mastodon
module Middleware
class SocketCleanup
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
ensure
clean_up_sockets!
end
private
def clean_up_sockets!
clean_up_redis_socket!
clean_up_statsd_socket!
end
def clean_up_redis_socket!
RedisConnection.pool.checkin if Thread.current[:redis]
Thread.current[:redis] = nil
end
def clean_up_statsd_socket!
Thread.current[:statsd_socket]&.close
Thread.current[:statsd_socket] = nil
end
end
end
end

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
class Mastodon::RackMiddleware
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
ensure
clean_up_sockets!
end
private
def clean_up_sockets!
clean_up_redis_socket!
clean_up_statsd_socket!
end
def clean_up_redis_socket!
RedisConnection.pool.checkin if Thread.current[:redis]
Thread.current[:redis] = nil
end
def clean_up_statsd_socket!
Thread.current[:statsd_socket]&.close
Thread.current[:statsd_socket] = nil
end
end

View File

@ -1,48 +0,0 @@
# frozen_string_literal: true
require 'action_dispatch/middleware/static'
class PublicFileServerMiddleware
SERVICE_WORKER_TTL = 7.days.to_i
CACHE_TTL = 28.days.to_i
def initialize(app)
@app = app
@file_handler = ActionDispatch::FileHandler.new(Rails.application.paths['public'].first)
end
def call(env)
file = @file_handler.attempt(env)
# If the request is not a static file, move on!
return @app.call(env) if file.nil?
status, headers, response = file
# Set cache headers on static files. Some paths require different cache headers
headers['Cache-Control'] = begin
request_path = env['REQUEST_PATH']
if request_path.start_with?('/sw.js')
"public, max-age=#{SERVICE_WORKER_TTL}, must-revalidate"
elsif request_path.start_with?(paperclip_root_url)
"public, max-age=#{CACHE_TTL}, immutable"
else
"public, max-age=#{CACHE_TTL}, must-revalidate"
end
end
# Override the default CSP header set by the CSP middleware
headers['Content-Security-Policy'] = "default-src 'none'; form-action 'none'" if request_path.start_with?(paperclip_root_url)
headers['X-Content-Type-Options'] = 'nosniff'
[status, headers, response]
end
private
def paperclip_root_url
ENV.fetch('PAPERCLIP_ROOT_URL', '/system')
end
end

View File

@ -1,25 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Admin::Settings::BrandingController do
render_views
describe 'When signed in as an admin' do
before do
sign_in Fabricate(:admin_user), scope: :user
end
describe 'PUT #update' do
it 'cannot create a setting value for a non-admin key' do
expect(Setting.new_setting_key).to be_blank
patch :update, params: { form_admin_settings: { new_setting_key: 'New key value' } }
expect(response)
.to have_http_status(400)
expect(Setting.new_setting_key).to be_nil
end
end
end
end

View File

@ -3,28 +3,23 @@
require 'rails_helper'
RSpec.describe Mastodon::Feature do
around do |example|
original_value = Rails.configuration.x.mastodon.experimental_features
Rails.configuration.x.mastodon.experimental_features = 'fasp,fetch_all_replies'
example.run
Rails.configuration.x.mastodon.experimental_features = original_value
end
describe '::fasp_enabled?' do
subject { described_class.fasp_enabled? }
it { is_expected.to be true }
end
describe '::fetch_all_replies_enabled?' do
subject { described_class.fetch_all_replies_enabled? }
describe '::testing_only_enabled?' do
subject { described_class.testing_only_enabled? }
it { is_expected.to be true }
end
describe '::unspecified_feature_enabled?' do
subject { described_class.unspecified_feature_enabled? }
context 'when example is not tagged with a feature' do
subject { described_class.unspecified_feature_enabled? }
it { is_expected.to be false }
it { is_expected.to be false }
end
context 'when example is tagged with a feature', feature: 'unspecified_feature' do
subject { described_class.unspecified_feature_enabled? }
it { is_expected.to be true }
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin Settings Branding' do
describe 'When signed in as an admin' do
before { sign_in Fabricate(:admin_user) }
describe 'PUT /admin/settings/branding' do
it 'cannot create a setting value for a non-admin key' do
expect { put admin_settings_branding_path, params: { form_admin_settings: { new_setting_key: 'New key value' } } }
.to_not change(Setting, :new_setting_key).from(nil)
expect(response)
.to have_http_status(400)
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
end
before { allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) }
let(:params) do
{
avatar: fixture_file_upload('avatar.gif', 'image/gif'),
@ -113,7 +111,7 @@ RSpec.describe 'credentials API' do
})
expect(ActivityPub::UpdateDistributionWorker)
.to have_received(:perform_async).with(user.account_id)
.to have_enqueued_sidekiq_job(user.account_id)
end
def expect_account_updates

View File

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

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
RSpec.configure do |config|
config.before(:example, :feature) do |example|
feature = example.metadata[:feature]
allow(Mastodon::Feature).to receive(:"#{feature}_enabled?").and_return(true)
end
end

View File

@ -22,7 +22,11 @@ module ProfileStories
def as_a_logged_in_user
as_a_registered_user
visit new_user_session_path
expect(page)
.to have_title(I18n.t('auth.login'))
fill_in_auth_details(email, password)
expect(page)
.to have_css('.app-holder')
end
def as_a_logged_in_admin

View File

@ -5,21 +5,15 @@ require 'rails_helper'
RSpec.describe 'NewStatuses', :inline_jobs, :js, :streaming do
include ProfileStories
subject { page }
let(:email) { 'test@example.com' }
let(:password) { 'password' }
let(:confirmed_at) { Time.zone.now }
let(:finished_onboarding) { true }
before do
as_a_logged_in_user
visit root_path
end
before { as_a_logged_in_user }
it 'can be posted' do
expect(subject).to have_css('div.app-holder')
visit_homepage
status_text = 'This is a new status!'
within('.compose-form') do
@ -27,12 +21,12 @@ RSpec.describe 'NewStatuses', :inline_jobs, :js, :streaming do
click_on 'Post'
end
expect(subject).to have_css('.status__content__text', text: status_text)
expect(page)
.to have_css('.status__content__text', text: status_text)
end
it 'can be posted again' do
expect(subject).to have_css('div.app-holder')
visit_homepage
status_text = 'This is a second status!'
within('.compose-form') do
@ -40,6 +34,15 @@ RSpec.describe 'NewStatuses', :inline_jobs, :js, :streaming do
click_on 'Post'
end
expect(subject).to have_css('.status__content__text', text: status_text)
expect(page)
.to have_css('.status__content__text', text: status_text)
end
def visit_homepage
visit root_path
expect(page)
.to have_css('div.app-holder')
.and have_css('form.compose-form')
end
end

View File

@ -40,5 +40,7 @@ RSpec.describe 'report interface', :attachment_processing, :js, :streaming do
within '.report-actions' do
click_on I18n.t('admin.reports.mark_as_resolved')
end
expect(page)
.to have_content(I18n.t('admin.reports.resolved_msg'))
end
end

View File

@ -11,8 +11,6 @@ RSpec.describe 'Settings Privacy' do
before { user.account.update(discoverable: false) }
context 'with a successful update' do
before { allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) }
it 'updates user profile information' do
# View settings page
visit settings_privacy_path
@ -29,14 +27,13 @@ RSpec.describe 'Settings Privacy' do
.to have_content(I18n.t('privacy.title'))
.and have_content(success_message)
expect(ActivityPub::UpdateDistributionWorker)
.to have_received(:perform_async).with(user.account.id)
.to have_enqueued_sidekiq_job(user.account.id)
end
end
context 'with a failed update' do
before do
allow(UpdateAccountService).to receive(:new).and_return(failing_update_service)
allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async)
end
it 'updates user profile information' do
@ -54,7 +51,7 @@ RSpec.describe 'Settings Privacy' do
expect(page)
.to have_content(I18n.t('privacy.title'))
expect(ActivityPub::UpdateDistributionWorker)
.to_not have_received(:perform_async)
.to_not have_enqueued_sidekiq_job(anything)
end
private

View File

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