From f7117646afddb2676e9275d8efe90c3a20c59021 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 12 Apr 2021 12:37:14 +0200 Subject: Add cold-start follow recommendations (#15945) --- app/controllers/api/v1/suggestions_controller.rb | 2 +- app/controllers/api/v2/suggestions_controller.rb | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v2/suggestions_controller.rb (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index 52054160d..b2788cc76 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -19,6 +19,6 @@ class Api::V1::SuggestionsController < Api::BaseController private def set_accounts - @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) + @accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT)) end end diff --git a/app/controllers/api/v2/suggestions_controller.rb b/app/controllers/api/v2/suggestions_controller.rb new file mode 100644 index 000000000..35eb276c0 --- /dev/null +++ b/app/controllers/api/v2/suggestions_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V2::SuggestionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_suggestions + + def index + render json: @suggestions, each_serializer: REST::SuggestionSerializer + end + + private + + def set_suggestions + @suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end +end -- cgit From ce2148c57111981be455a9953d3bb589cf53967f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 15 Apr 2021 05:00:25 +0200 Subject: Add `policy` param to `POST /api/v1/push/subscriptions` (#16040) With possible values `all`, `followed`, `follower`, and `none`, control from whom notifications will generate a Web Push alert --- .../api/v1/push/subscriptions_controller.rb | 28 +++---- .../api/web/push_subscriptions_controller.rb | 25 +++--- app/models/web/push_subscription.rb | 23 +++++- .../api/v1/push/subscriptions_controller_spec.rb | 28 ++++--- .../api/web/push_subscriptions_controller_spec.rb | 23 ++++-- spec/models/web/push_subscription_spec.rb | 94 ++++++++++++++++++++-- 6 files changed, 170 insertions(+), 51 deletions(-) (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 0918c61e9..47f2e6440 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -3,13 +3,13 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController before_action -> { doorkeeper_authorize! :push } before_action :require_user! - before_action :set_web_push_subscription - before_action :check_web_push_subscription, only: [:show, :update] + before_action :set_push_subscription + before_action :check_push_subscription, only: [:show, :update] def create - @web_subscription&.destroy! + @push_subscription&.destroy! - @web_subscription = ::Web::PushSubscription.create!( + @push_subscription = Web::PushSubscription.create!( endpoint: subscription_params[:endpoint], key_p256dh: subscription_params[:keys][:p256dh], key_auth: subscription_params[:keys][:auth], @@ -18,31 +18,31 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController access_token_id: doorkeeper_token.id ) - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def show - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def update - @web_subscription.update!(data: data_params) - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + @push_subscription.update!(data: data_params) + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def destroy - @web_subscription&.destroy! + @push_subscription&.destroy! render_empty end private - def set_web_push_subscription - @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) + def set_push_subscription + @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) end - def check_web_push_subscription - not_found if @web_subscription.nil? + def check_push_subscription + not_found if @push_subscription.nil? end def subscription_params @@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) + params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) end end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 1dce3e70f..bed57fc54 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -2,6 +2,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController before_action :require_user! + before_action :set_push_subscription, only: :update def create active_session = current_session @@ -15,9 +16,11 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet? data = { + policy: 'all', + alerts: { follow: alerts_enabled, - follow_request: false, + follow_request: alerts_enabled, favourite: alerts_enabled, reblog: alerts_enabled, mention: alerts_enabled, @@ -28,7 +31,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController data.deep_merge!(data_params) if params[:data] - web_subscription = ::Web::PushSubscription.create!( + push_subscription = ::Web::PushSubscription.create!( endpoint: subscription_params[:endpoint], key_p256dh: subscription_params[:keys][:p256dh], key_auth: subscription_params[:keys][:auth], @@ -37,27 +40,27 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController access_token_id: active_session.access_token_id ) - active_session.update!(web_push_subscription: web_subscription) + active_session.update!(web_push_subscription: push_subscription) - render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer end def update - params.require([:id]) - - web_subscription = ::Web::PushSubscription.find(params[:id]) - web_subscription.update!(data: data_params) - - render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer + @push_subscription.update!(data: data_params) + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end private + def set_push_subscription + @push_subscription = ::Web::PushSubscription.find(params[:id]) + end + def subscription_params @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) end def data_params - @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) + @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) end end diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb index 7609b1bfc..6e46573ae 100644 --- a/app/models/web/push_subscription.rb +++ b/app/models/web/push_subscription.rb @@ -47,7 +47,7 @@ class Web::PushSubscription < ApplicationRecord end def pushable?(notification) - ActiveModel::Type::Boolean.new.cast(data&.dig('alerts', notification.type.to_s)) + policy_allows_notification?(notification) && alert_enabled_for_notification_type?(notification) end def associated_user @@ -100,4 +100,25 @@ class Web::PushSubscription < ApplicationRecord def contact_email @contact_email ||= ::Setting.site_contact_email end + + def alert_enabled_for_notification_type?(notification) + truthy?(data&.dig('alerts', notification.type.to_s)) + end + + def policy_allows_notification?(notification) + case data&.dig('policy') + when nil, 'all' + true + when 'none' + false + when 'followed' + notification.account.following?(notification.from_account) + when 'follower' + notification.from_account.following?(notification.account) + end + end + + def truthy?(val) + ActiveModel::Type::Boolean.new.cast(val) + end end diff --git a/spec/controllers/api/v1/push/subscriptions_controller_spec.rb b/spec/controllers/api/v1/push/subscriptions_controller_spec.rb index 01146294f..534d02879 100644 --- a/spec/controllers/api/v1/push/subscriptions_controller_spec.rb +++ b/spec/controllers/api/v1/push/subscriptions_controller_spec.rb @@ -27,20 +27,27 @@ describe Api::V1::Push::SubscriptionsController do let(:alerts_payload) do { data: { + policy: 'all', + alerts: { follow: true, + follow_request: true, favourite: false, reblog: true, mention: false, + poll: true, + status: false, } } }.with_indifferent_access end describe 'POST #create' do - it 'saves push subscriptions' do + before do post :create, params: create_payload + end + it 'saves push subscriptions' do push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint]) expect(push_subscription.endpoint).to eq(create_payload[:subscription][:endpoint]) @@ -52,31 +59,34 @@ describe Api::V1::Push::SubscriptionsController do it 'replaces old subscription on repeat calls' do post :create, params: create_payload - post :create, params: create_payload - expect(Web::PushSubscription.where(endpoint: create_payload[:subscription][:endpoint]).count).to eq 1 end end describe 'PUT #update' do - it 'changes alert settings' do + before do post :create, params: create_payload put :update, params: alerts_payload + end + it 'changes alert settings' do push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint]) - expect(push_subscription.data.dig('alerts', 'follow')).to eq(alerts_payload[:data][:alerts][:follow].to_s) - expect(push_subscription.data.dig('alerts', 'favourite')).to eq(alerts_payload[:data][:alerts][:favourite].to_s) - expect(push_subscription.data.dig('alerts', 'reblog')).to eq(alerts_payload[:data][:alerts][:reblog].to_s) - expect(push_subscription.data.dig('alerts', 'mention')).to eq(alerts_payload[:data][:alerts][:mention].to_s) + expect(push_subscription.data['policy']).to eq(alerts_payload[:data][:policy]) + + %w(follow follow_request favourite reblog mention poll status).each do |type| + expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s) + end end end describe 'DELETE #destroy' do - it 'removes the subscription' do + before do post :create, params: create_payload delete :destroy + end + it 'removes the subscription' do expect(Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])).to be_nil end end diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb index 381cdeab9..bda4a7661 100644 --- a/spec/controllers/api/web/push_subscriptions_controller_spec.rb +++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb @@ -22,11 +22,16 @@ describe Api::Web::PushSubscriptionsController do let(:alerts_payload) do { data: { + policy: 'all', + alerts: { follow: true, + follow_request: false, favourite: false, reblog: true, mention: false, + poll: true, + status: false, } } } @@ -59,10 +64,11 @@ describe Api::Web::PushSubscriptionsController do push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint]) - expect(push_subscription.data['alerts']['follow']).to eq(alerts_payload[:data][:alerts][:follow].to_s) - expect(push_subscription.data['alerts']['favourite']).to eq(alerts_payload[:data][:alerts][:favourite].to_s) - expect(push_subscription.data['alerts']['reblog']).to eq(alerts_payload[:data][:alerts][:reblog].to_s) - expect(push_subscription.data['alerts']['mention']).to eq(alerts_payload[:data][:alerts][:mention].to_s) + expect(push_subscription.data['policy']).to eq 'all' + + %w(follow follow_request favourite reblog mention poll status).each do |type| + expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s) + end end end end @@ -81,10 +87,11 @@ describe Api::Web::PushSubscriptionsController do push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint]) - expect(push_subscription.data['alerts']['follow']).to eq(alerts_payload[:data][:alerts][:follow].to_s) - expect(push_subscription.data['alerts']['favourite']).to eq(alerts_payload[:data][:alerts][:favourite].to_s) - expect(push_subscription.data['alerts']['reblog']).to eq(alerts_payload[:data][:alerts][:reblog].to_s) - expect(push_subscription.data['alerts']['mention']).to eq(alerts_payload[:data][:alerts][:mention].to_s) + expect(push_subscription.data['policy']).to eq 'all' + + %w(follow follow_request favourite reblog mention poll status).each do |type| + expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s) + end end end end diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb index c6665611c..b44904369 100644 --- a/spec/models/web/push_subscription_spec.rb +++ b/spec/models/web/push_subscription_spec.rb @@ -1,16 +1,94 @@ require 'rails_helper' RSpec.describe Web::PushSubscription, type: :model do - let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } } - let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) } + let(:account) { Fabricate(:account) } + + let(:policy) { 'all' } + + let(:data) do + { + policy: policy, + + alerts: { + mention: true, + reblog: false, + follow: true, + follow_request: false, + favourite: true, + }, + } + end + + subject { described_class.new(data: data) } describe '#pushable?' do - it 'obeys alert settings' do - expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true - expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false - expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true - expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false - expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true + let(:notification_type) { :mention } + let(:notification) { Fabricate(:notification, account: account, type: notification_type) } + + %i(mention reblog follow follow_request favourite).each do |type| + context "when notification is a #{type}" do + let(:notification_type) { type } + + it "returns boolean corresonding to alert setting" do + expect(subject.pushable?(notification)).to eq data[:alerts][type] + end + end + end + + context 'when policy is all' do + let(:policy) { 'all' } + + it 'returns true' do + expect(subject.pushable?(notification)).to eq true + end + end + + context 'when policy is none' do + let(:policy) { 'none' } + + it 'returns false' do + expect(subject.pushable?(notification)).to eq false + end + end + + context 'when policy is followed' do + let(:policy) { 'followed' } + + context 'and notification is from someone you follow' do + before do + account.follow!(notification.from_account) + end + + it 'returns true' do + expect(subject.pushable?(notification)).to eq true + end + end + + context 'and notification is not from someone you follow' do + it 'returns false' do + expect(subject.pushable?(notification)).to eq false + end + end + end + + context 'when policy is follower' do + let(:policy) { 'follower' } + + context 'and notification is from someone who follows you' do + before do + notification.from_account.follow!(account) + end + + it 'returns true' do + expect(subject.pushable?(notification)).to eq true + end + end + + context 'and notification is not from someone who follows you' do + it 'returns false' do + expect(subject.pushable?(notification)).to eq false + end + end end end end -- cgit From daccc07dc170627b17564402296f6c8631d0cd97 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 24 Apr 2021 17:01:43 +0200 Subject: Change auto-following admin-selected accounts, show in recommendations (#16078) --- app/controllers/api/v1/suggestions_controller.rb | 10 ++-- app/lib/potential_friendship_tracker.rb | 10 ---- app/models/account_suggestions.rb | 25 +++++--- app/models/account_suggestions/global_source.rb | 37 ++++++++++++ .../past_interactions_source.rb | 36 ++++++++++++ app/models/account_suggestions/setting_source.rb | 68 ++++++++++++++++++++++ app/models/account_suggestions/source.rb | 34 +++++++++++ app/models/account_suggestions/suggestion.rb | 7 +++ app/models/follow_recommendation.rb | 15 ----- app/models/form/admin_settings.rb | 2 - app/services/bootstrap_timeline_service.rb | 37 +----------- app/validators/existing_username_validator.rb | 24 ++++++-- app/views/admin/settings/edit.html.haml | 5 +- config/locales/en.yml | 7 +-- config/settings.yml | 1 - spec/services/bootstrap_timeline_service_spec.rb | 38 ------------ 16 files changed, 228 insertions(+), 128 deletions(-) create mode 100644 app/models/account_suggestions/global_source.rb create mode 100644 app/models/account_suggestions/past_interactions_source.rb create mode 100644 app/models/account_suggestions/setting_source.rb create mode 100644 app/models/account_suggestions/source.rb create mode 100644 app/models/account_suggestions/suggestion.rb (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index b2788cc76..9737ae5cb 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -5,20 +5,20 @@ class Api::V1::SuggestionsController < Api::BaseController before_action -> { doorkeeper_authorize! :read } before_action :require_user! - before_action :set_accounts def index - render json: @accounts, each_serializer: REST::AccountSerializer + suggestions = suggestions_source.get(current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) + render json: suggestions.map(&:account), each_serializer: REST::AccountSerializer end def destroy - PotentialFriendshipTracker.remove(current_account.id, params[:id]) + suggestions_source.remove(current_account, params[:id]) render_empty end private - def set_accounts - @accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT)) + def suggestions_source + AccountSuggestions::PastInteractionsSource.new end end diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb index e72d454b6..f5bc20346 100644 --- a/app/lib/potential_friendship_tracker.rb +++ b/app/lib/potential_friendship_tracker.rb @@ -27,15 +27,5 @@ class PotentialFriendshipTracker def remove(account_id, target_account_id) redis.zrem("interactions:#{account_id}", target_account_id) end - - def get(account, limit) - account_ids = redis.zrevrange("interactions:#{account.id}", 0, limit) - - return [] if account_ids.empty? || limit < 1 - - accounts = Account.searchable.where(id: account_ids).index_by(&:id) - - account_ids.map { |id| accounts[id.to_i] }.compact - end end end diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb index 7fe9d618e..d1774e62f 100644 --- a/app/models/account_suggestions.rb +++ b/app/models/account_suggestions.rb @@ -1,17 +1,28 @@ # frozen_string_literal: true class AccountSuggestions - class Suggestion < ActiveModelSerializers::Model - attributes :account, :source - end + SOURCES = [ + AccountSuggestions::SettingSource, + AccountSuggestions::PastInteractionsSource, + AccountSuggestions::GlobalSource, + ].freeze def self.get(account, limit) - suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) } - suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit - suggestions + SOURCES.each_with_object([]) do |source_class, suggestions| + source_suggestions = source_class.new.get( + account, + skip_account_ids: suggestions.map(&:account_id), + limit: limit - suggestions.size + ) + + suggestions.concat(source_suggestions) + end end def self.remove(account, target_account_id) - PotentialFriendshipTracker.remove(account.id, target_account_id) + SOURCES.each do |source_class| + source = source_class.new + source.remove(account, target_account_id) + end end end diff --git a/app/models/account_suggestions/global_source.rb b/app/models/account_suggestions/global_source.rb new file mode 100644 index 000000000..ac764de50 --- /dev/null +++ b/app/models/account_suggestions/global_source.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class AccountSuggestions::GlobalSource < AccountSuggestions::Source + def key + :global + end + + def get(account, skip_account_ids: [], limit: 40) + account_ids = account_ids_for_locale(account.user_locale) - [account.id] - skip_account_ids + + as_ordered_suggestions( + scope(account).where(id: account_ids), + account_ids + ).take(limit) + end + + def remove(_account, _target_account_id) + nil + end + + private + + def scope(account) + Account.searchable + .followable_by(account) + .not_excluded_by_account(account) + .not_domain_blocked_by_account(account) + end + + def account_ids_for_locale(locale) + Redis.current.zrevrange("follow_recommendations:#{locale}", 0, -1).map(&:to_i) + end + + def to_ordered_list_key(account) + account.id + end +end diff --git a/app/models/account_suggestions/past_interactions_source.rb b/app/models/account_suggestions/past_interactions_source.rb new file mode 100644 index 000000000..d169394f1 --- /dev/null +++ b/app/models/account_suggestions/past_interactions_source.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AccountSuggestions::PastInteractionsSource < AccountSuggestions::Source + include Redisable + + def key + :past_interactions + end + + def get(account, skip_account_ids: [], limit: 40) + account_ids = account_ids_for_account(account.id, limit + skip_account_ids.size) - skip_account_ids + + as_ordered_suggestions( + scope.where(id: account_ids), + account_ids + ).take(limit) + end + + def remove(account, target_account_id) + redis.zrem("interactions:#{account.id}", target_account_id) + end + + private + + def scope + Account.searchable + end + + def account_ids_for_account(account_id, limit) + redis.zrevrange("interactions:#{account_id}", 0, limit).map(&:to_i) + end + + def to_ordered_list_key(account) + account.id + end +end diff --git a/app/models/account_suggestions/setting_source.rb b/app/models/account_suggestions/setting_source.rb new file mode 100644 index 000000000..be9eff233 --- /dev/null +++ b/app/models/account_suggestions/setting_source.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class AccountSuggestions::SettingSource < AccountSuggestions::Source + def key + :staff + end + + def get(account, skip_account_ids: [], limit: 40) + return [] unless setting_enabled? + + as_ordered_suggestions( + scope(account).where(setting_to_where_condition).where.not(id: skip_account_ids), + usernames_and_domains + ).take(limit) + end + + def remove(_account, _target_account_id) + nil + end + + private + + def scope(account) + Account.searchable + .followable_by(account) + .not_excluded_by_account(account) + .not_domain_blocked_by_account(account) + .where(locked: false) + .where.not(id: account.id) + end + + def usernames_and_domains + @usernames_and_domains ||= setting_to_usernames_and_domains + end + + def setting_enabled? + setting.present? + end + + def setting_to_where_condition + usernames_and_domains.map do |(username, domain)| + Arel::Nodes::Grouping.new( + Account.arel_table[:username].lower.eq(username.downcase).and( + Account.arel_table[:domain].lower.eq(domain&.downcase) + ) + ) + end.reduce(:or) + end + + def setting_to_usernames_and_domains + setting.split(',').map do |str| + username, domain = str.strip.gsub(/\A@/, '').split('@', 2) + domain = nil if TagManager.instance.local_domain?(domain) + + next if username.blank? + + [username, domain] + end.compact + end + + def setting + Setting.bootstrap_timeline_accounts + end + + def to_ordered_list_key(account) + [account.username, account.domain] + end +end diff --git a/app/models/account_suggestions/source.rb b/app/models/account_suggestions/source.rb new file mode 100644 index 000000000..bd1068d20 --- /dev/null +++ b/app/models/account_suggestions/source.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class AccountSuggestions::Source + def key + raise NotImplementedError + end + + def get(_account, **kwargs) + raise NotImplementedError + end + + def remove(_account, target_account_id) + raise NotImplementedError + end + + protected + + def as_ordered_suggestions(scope, ordered_list) + return [] if ordered_list.empty? + + map = scope.index_by(&method(:to_ordered_list_key)) + + ordered_list.map { |ordered_list_key| map[ordered_list_key] }.compact.map do |account| + AccountSuggestions::Suggestion.new( + account: account, + source: key + ) + end + end + + def to_ordered_list_key(_account) + raise NotImplementedError + end +end diff --git a/app/models/account_suggestions/suggestion.rb b/app/models/account_suggestions/suggestion.rb new file mode 100644 index 000000000..2c6f4d27f --- /dev/null +++ b/app/models/account_suggestions/suggestion.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AccountSuggestions::Suggestion < ActiveModelSerializers::Model + attributes :account, :source + + delegate :id, to: :account, prefix: true +end diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb index c4355224d..6670b6560 100644 --- a/app/models/follow_recommendation.rb +++ b/app/models/follow_recommendation.rb @@ -21,19 +21,4 @@ class FollowRecommendation < ApplicationRecord def readonly? true end - - def self.get(account, limit, exclude_account_ids = []) - account_ids = Redis.current.zrevrange("follow_recommendations:#{account.user_locale}", 0, -1).map(&:to_i) - exclude_account_ids - [account.id] - - return [] if account_ids.empty? || limit < 1 - - accounts = Account.followable_by(account) - .not_excluded_by_account(account) - .not_domain_blocked_by_account(account) - .where(id: account_ids) - .limit(limit) - .index_by(&:id) - - account_ids.map { |id| accounts[id] }.compact - end end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index b5c3dcdbe..6fc7c56fd 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -16,7 +16,6 @@ class Form::AdminSettings open_deletion timeline_preview show_staff_badge - enable_bootstrap_timeline_accounts bootstrap_timeline_accounts theme min_invite_role @@ -41,7 +40,6 @@ class Form::AdminSettings open_deletion timeline_preview show_staff_badge - enable_bootstrap_timeline_accounts activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb index 8412aa7e7..e1a1b98c3 100644 --- a/app/services/bootstrap_timeline_service.rb +++ b/app/services/bootstrap_timeline_service.rb @@ -5,48 +5,13 @@ class BootstrapTimelineService < BaseService @source_account = source_account autofollow_inviter! - autofollow_bootstrap_timeline_accounts! if Setting.enable_bootstrap_timeline_accounts end private def autofollow_inviter! return unless @source_account&.user&.invite&.autofollow? - FollowService.new.call(@source_account, @source_account.user.invite.user.account) - end - - def autofollow_bootstrap_timeline_accounts! - bootstrap_timeline_accounts.each do |target_account| - begin - FollowService.new.call(@source_account, target_account) - rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError - nil - end - end - end - - def bootstrap_timeline_accounts - return @bootstrap_timeline_accounts if defined?(@bootstrap_timeline_accounts) - - @bootstrap_timeline_accounts = bootstrap_timeline_accounts_usernames.empty? ? admin_accounts : local_unlocked_accounts(bootstrap_timeline_accounts_usernames) - end - - def bootstrap_timeline_accounts_usernames - @bootstrap_timeline_accounts_usernames ||= (Setting.bootstrap_timeline_accounts || '').split(',').map { |str| str.strip.gsub(/\A@/, '') }.reject(&:blank?) - end - def admin_accounts - User.admins - .includes(:account) - .where(accounts: { locked: false }) - .map(&:account) - end - - def local_unlocked_accounts(usernames) - Account.local - .without_suspended - .where(username: usernames) - .where(locked: false) - .where(moved_to_account_id: nil) + FollowService.new.call(@source_account, @source_account.user.invite.user.account) end end diff --git a/app/validators/existing_username_validator.rb b/app/validators/existing_username_validator.rb index 723302ec9..afbe0c635 100644 --- a/app/validators/existing_username_validator.rb +++ b/app/validators/existing_username_validator.rb @@ -4,11 +4,25 @@ class ExistingUsernameValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return if value.blank? - if options[:multiple] - missing_usernames = value.split(',').map { |username| username.strip.gsub(/\A@/, '') }.filter_map { |username| username unless Account.find_local(username) } - record.errors.add(attribute, I18n.t('existing_username_validator.not_found_multiple', usernames: missing_usernames.join(', '))) if missing_usernames.any? - else - record.errors.add(attribute, I18n.t('existing_username_validator.not_found')) unless Account.find_local(value.strip.gsub(/\A@/, '')) + usernames_and_domains = begin + value.split(',').map do |str| + username, domain = str.strip.gsub(/\A@/, '').split('@') + domain = nil if TagManager.instance.local_domain?(domain) + + next if username.blank? + + [str, username, domain] + end.compact + end + + usernames_with_no_accounts = usernames_and_domains.filter_map do |(str, username, domain)| + str unless Account.find_remote(username, domain) + end + + if usernames_with_no_accounts.any? && options[:multiple] + record.errors.add(attribute, I18n.t('existing_username_validator.not_found_multiple', usernames: usernames_with_no_accounts.join(', '))) + elsif usernames_with_no_accounts.any? || usernames_and_domains.size > 1 + record.errors.add(attribute, I18n.t('existing_username_validator.not_found')) end end end diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 7783dbfeb..33bfc43d3 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -50,10 +50,7 @@ %hr.spacer/ .fields-group - = f.input :enable_bootstrap_timeline_accounts, as: :boolean, wrapper: :with_label, label: t('admin.settings.enable_bootstrap_timeline_accounts.title'), hint: t('admin.settings.enable_bootstrap_timeline_accounts.desc_html') - - .fields-group - = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html'), disabled: !Setting.enable_bootstrap_timeline_accounts + = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html') %hr.spacer/ diff --git a/config/locales/en.yml b/config/locales/en.yml index 765f71250..1b41ee063 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -564,8 +564,8 @@ en: desc_html: Counts of locally published posts, active users, and new registrations in weekly buckets title: Publish aggregate statistics about user activity in the API bootstrap_timeline_accounts: - desc_html: Separate multiple usernames by comma. Only local and unlocked accounts will work. Default when empty is all local admins. - title: Default follows for new users + desc_html: Separate multiple usernames by comma. These accounts will be guaranteed to be shown in follow recommendations + title: Recommend these accounts to new users contact_information: email: Business e-mail username: Contact username @@ -582,9 +582,6 @@ en: users: To logged-in local users domain_blocks_rationale: title: Show rationale - enable_bootstrap_timeline_accounts: - desc_html: Make new users automatically follow configured accounts so their home feed doesn't start out empty - title: Enable default follows for new users hero: desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to server thumbnail title: Hero image diff --git a/config/settings.yml b/config/settings.yml index b79ea620c..06cee2532 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -62,7 +62,6 @@ defaults: &defaults - mod - moderator disallowed_hashtags: # space separated string or list of hashtags without the hash - enable_bootstrap_timeline_accounts: true bootstrap_timeline_accounts: '' activity_api_enabled: true peers_api_enabled: true diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb index a28d2407c..880ca4f0d 100644 --- a/spec/services/bootstrap_timeline_service_spec.rb +++ b/spec/services/bootstrap_timeline_service_spec.rb @@ -1,42 +1,4 @@ require 'rails_helper' RSpec.describe BootstrapTimelineService, type: :service do - subject { described_class.new } - - describe '#call' do - let(:source_account) { Fabricate(:account) } - - context 'when setting is empty' do - let!(:admin) { Fabricate(:user, admin: true) } - - before do - Setting.bootstrap_timeline_accounts = nil - subject.call(source_account) - end - - it 'follows admin accounts from account' do - expect(source_account.following?(admin.account)).to be true - end - end - - context 'when setting is set' do - let!(:alice) { Fabricate(:account, username: 'alice') } - let!(:bob) { Fabricate(:account, username: 'bob') } - let!(:eve) { Fabricate(:account, username: 'eve', suspended: true) } - - before do - Setting.bootstrap_timeline_accounts = 'alice, @bob, eve, unknown' - subject.call(source_account) - end - - it 'follows found accounts from account' do - expect(source_account.following?(alice)).to be true - expect(source_account.following?(bob)).to be true - end - - it 'does not follow suspended account' do - expect(source_account.following?(eve)).to be false - end - end - end end -- cgit From 1f4751102364a3225233ad7d97a68e165c30ba64 Mon Sep 17 00:00:00 2001 From: abcang Date: Sun, 25 Apr 2021 13:34:48 +0900 Subject: Improve media attached status query (#16105) --- app/controllers/accounts_controller.rb | 6 +----- app/controllers/admin/statuses_controller.rb | 3 +-- app/controllers/api/v1/accounts/statuses_controller.rb | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) (limited to 'app/controllers/api') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 7753ebccc..f15961552 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -77,11 +77,7 @@ class AccountsController < ApplicationController end def only_media_scope - Status.where(id: account_media_status_ids) - end - - def account_media_status_ids - @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id) + Status.joins(:media_attachments).group(:id) end def no_replies_scope diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index d7c192f0d..df1bd1bc7 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -14,8 +14,7 @@ module Admin @statuses = @account.statuses.where(visibility: [:public, :unlisted]) if params[:media] - account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id) - @statuses.merge!(Status.where(id: account_media_status_ids)) + @statuses.merge!(Status.joins(:media_attachments).group(:id)) end @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 92ccb8061..b1e478f8d 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -42,7 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def only_media_scope - Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) + Status.joins(:media_attachments).group(:id) end def pinned_scope -- cgit From d0fc69d7213cab03c57947cac94bad1fd9cc6dba Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 27 Apr 2021 01:57:46 +0900 Subject: Further improve the media attached status query for accounts (#16106) --- app/controllers/accounts_controller.rb | 2 +- app/controllers/admin/statuses_controller.rb | 2 +- app/controllers/api/v1/accounts/statuses_controller.rb | 2 +- ...2_add_index_on_media_attachments_account_id_status_id.rb | 13 +++++++++++++ db/schema.rb | 4 ++-- 5 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb (limited to 'app/controllers/api') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index f15961552..8210918d8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -77,7 +77,7 @@ class AccountsController < ApplicationController end def only_media_scope - Status.joins(:media_attachments).group(:id) + Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) end def no_replies_scope diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index df1bd1bc7..ef279509d 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -14,7 +14,7 @@ module Admin @statuses = @account.statuses.where(visibility: [:public, :unlisted]) if params[:media] - @statuses.merge!(Status.joins(:media_attachments).group(:id)) + @statuses.merge!(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)) end @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index b1e478f8d..92ccb8061 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -42,7 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def only_media_scope - Status.joins(:media_attachments).group(:id) + Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) end def pinned_scope diff --git a/db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb b/db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb new file mode 100644 index 000000000..5ef2d3c39 --- /dev/null +++ b/db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb @@ -0,0 +1,13 @@ +class AddIndexOnMediaAttachmentsAccountIdStatusId < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + add_index :media_attachments, [:account_id, :status_id], order: { status_id: :desc }, algorithm: :concurrently + remove_index :media_attachments, :account_id, algorithm: :concurrently + end + + def down + add_index :media_attachments, :account_id, algorithm: :concurrently + remove_index :media_attachments, [:account_id, :status_id], order: { status_id: :desc }, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 8dc0661fc..0d951ee95 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_04_21_121431) do +ActiveRecord::Schema.define(version: 2021_04_25_135952) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -541,7 +541,7 @@ ActiveRecord::Schema.define(version: 2021_04_21_121431) do t.integer "thumbnail_file_size" t.datetime "thumbnail_updated_at" t.string "thumbnail_remote_url" - t.index ["account_id"], name: "index_media_attachments_on_account_id" + t.index ["account_id", "status_id"], name: "index_media_attachments_on_account_id_and_status_id", order: { status_id: :desc } t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id" t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true t.index ["status_id"], name: "index_media_attachments_on_status_id" -- cgit From 566fc909134586d1746ad60ee455832dec6bc61a Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 6 May 2021 14:22:54 +0200 Subject: Add Ruby 3.0 support (#16046) * Fix issues with POSIX::Spawn, Terrapin and Ruby 3.0 Also improve the Terrapin monkey-patch for the stderr/stdout issue. * Fix keyword argument handling throughout the codebase * Monkey-patch Paperclip to fix keyword arguments handling in validators * Change validation_extensions to please CodeClimate * Bump microformats from 4.2.1 to 4.3.1 * Allow Ruby 3.0 * Add Ruby 3.0 test target to CircleCI * Add test for admin dashboard warnings * Fix admin dashboard warnings on Ruby 3.0 --- .circleci/config.yml | 27 +++++++ Gemfile | 2 +- Gemfile.lock | 6 +- app/controllers/activitypub/outboxes_controller.rb | 2 +- app/controllers/api/v1/accounts_controller.rb | 4 +- .../api/v1/follow_requests_controller.rb | 2 +- app/models/session_activation.rb | 2 +- app/models/user.rb | 19 +++-- app/views/admin/dashboard/index.html.haml | 2 +- app/workers/import/relationship_worker.rb | 6 +- config/application.rb | 1 + config/initializers/session_store.rb | 5 +- lib/paperclip/validation_extensions.rb | 58 +++++++++++++++ lib/terrapin/multi_pipe_extensions.rb | 87 +++++++++++----------- .../controllers/admin/dashboard_controller_spec.rb | 12 ++- spec/mailers/notification_mailer_spec.rb | 4 +- spec/mailers/user_mailer_spec.rb | 4 +- spec/models/session_activation_spec.rb | 6 +- .../account_relationships_presenter_spec.rb | 2 +- 19 files changed, 177 insertions(+), 74 deletions(-) create mode 100644 lib/paperclip/validation_extensions.rb (limited to 'app/controllers/api') diff --git a/.circleci/config.yml b/.circleci/config.yml index 862fa126b..2f3860d7c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -129,6 +129,13 @@ jobs: environment: *ruby_environment <<: *install_ruby_dependencies + install-ruby3.0: + <<: *defaults + docker: + - image: circleci/ruby:3.0-buster-node + environment: *ruby_environment + <<: *install_ruby_dependencies + build: <<: *defaults steps: @@ -187,6 +194,18 @@ jobs: - image: circleci/redis:5-alpine <<: *test_steps + test-ruby3.0: + <<: *defaults + docker: + - image: circleci/ruby:3.0-buster-node + environment: *ruby_environment + - image: circleci/postgres:12.2 + environment: + POSTGRES_USER: root + POSTGRES_HOST_AUTH_METHOD: trust + - image: circleci/redis:5-alpine + <<: *test_steps + test-webui: <<: *defaults docker: @@ -227,6 +246,10 @@ workflows: requires: - install - install-ruby2.7 + - install-ruby3.0: + requires: + - install + - install-ruby2.7 - build: requires: - install-ruby2.7 @@ -241,6 +264,10 @@ workflows: requires: - install-ruby2.6 - build + - test-ruby3.0: + requires: + - install-ruby3.0 + - build - test-webui: requires: - install diff --git a/Gemfile b/Gemfile index 6ca0a81de..5a55d6b04 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 2.5.0', '< 3.0.0' +ruby '>= 2.5.0', '< 3.1.0' gem 'pkg-config', '~> 1.4' diff --git a/Gemfile.lock b/Gemfile.lock index b1ae4fd22..980750b63 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -292,7 +292,7 @@ GEM ipaddress (0.8.3) iso-639 (0.3.5) jmespath (1.4.0) - json (2.3.1) + json (2.5.1) json-canonicalization (0.2.1) json-ld (3.1.9) htmlentities (~> 4.3) @@ -344,7 +344,7 @@ GEM redis (>= 3.0.5) memory_profiler (1.0.0) method_source (1.0.0) - microformats (4.2.1) + microformats (4.3.1) json (~> 2.2) nokogiri (~> 1.10) mime-types (3.3.1) @@ -354,7 +354,7 @@ GEM nokogiri (~> 1) rake mini_mime (1.0.3) - mini_portile2 (2.5.0) + mini_portile2 (2.5.1) minitest (5.14.4) msgpack (1.4.2) multi_json (1.15.0) diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 5fd735ad6..111285036 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -20,7 +20,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def outbox_presenter if page_requested? ActivityPub::CollectionPresenter.new( - id: outbox_url(page_params), + id: outbox_url(**page_params), type: :ordered, part_of: outbox_url, prev: prev_page, diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 996f1b79b..95869f554 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -35,7 +35,7 @@ class Api::V1::AccountsController < Api::BaseController follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true) options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } } - render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) end def block @@ -70,7 +70,7 @@ class Api::V1::AccountsController < Api::BaseController end def relationships(**options) - AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) + AccountRelationshipsPresenter.new([@account.id], current_user.account_id, **options) end def account_params diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index b34c76f29..f4b2a74d0 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -29,7 +29,7 @@ class Api::V1::FollowRequestsController < Api::BaseController end def relationships(**options) - AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, options) + AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, **options) end def load_accounts diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index b0ce9d112..3a59bad93 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -44,7 +44,7 @@ class SessionActivation < ApplicationRecord end def activate(**options) - activation = create!(options) + activation = create!(**options) purge_old activation end diff --git a/app/models/user.rb b/app/models/user.rb index 0440627c5..4973c68b6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -370,15 +370,20 @@ class User < ApplicationRecord protected - def send_devise_notification(notification, *args) + def send_devise_notification(notification, *args, **kwargs) # This method can be called in `after_update` and `after_commit` hooks, # but we must make sure the mailer is actually called *after* commit, # otherwise it may work on stale data. To do this, figure out if we are # within a transaction. + + # It seems like devise sends keyword arguments as a hash in the last + # positional argument + kwargs = args.pop if args.last.is_a?(Hash) && kwargs.empty? + if ActiveRecord::Base.connection.current_transaction.try(:records)&.include?(self) - pending_devise_notifications << [notification, args] + pending_devise_notifications << [notification, args, kwargs] else - render_and_send_devise_message(notification, *args) + render_and_send_devise_message(notification, *args, **kwargs) end end @@ -389,8 +394,8 @@ class User < ApplicationRecord end def send_pending_devise_notifications - pending_devise_notifications.each do |notification, args| - render_and_send_devise_message(notification, *args) + pending_devise_notifications.each do |notification, args, kwargs| + render_and_send_devise_message(notification, *args, **kwargs) end # Empty the pending notifications array because the @@ -403,8 +408,8 @@ class User < ApplicationRecord @pending_devise_notifications ||= [] end - def render_and_send_devise_message(notification, *args) - devise_mailer.send(notification, self, *args).deliver_later + def render_and_send_devise_message(notification, *args, **kwargs) + devise_mailer.send(notification, self, *args, **kwargs).deliver_later end def set_approved diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 57a753e6b..e8a2b46fd 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -5,7 +5,7 @@ .flash-message-stack - @system_checks.each do |message| .flash-message.warning - = t("admin.system_checks.#{message.key}.message_html", message.value ? { value: content_tag(:strong, message.value) } : {}) + = t("admin.system_checks.#{message.key}.message_html", value: message.value ? content_tag(:strong, message.value) : nil) - if message.action = link_to t("admin.system_checks.#{message.key}.action"), message.action diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb index 4a7100435..6791b15c3 100644 --- a/app/workers/import/relationship_worker.rb +++ b/app/workers/import/relationship_worker.rb @@ -5,7 +5,7 @@ class Import::RelationshipWorker sidekiq_options queue: 'pull', retry: 8, dead: false - def perform(account_id, target_account_uri, relationship, options = {}) + def perform(account_id, target_account_uri, relationship, options) from_account = Account.find(account_id) target_domain = domain(target_account_uri) target_account = stoplight_wrap_request(target_domain) { ResolveAccountService.new.call(target_account_uri, { check_delivery_availability: true }) } @@ -16,7 +16,7 @@ class Import::RelationshipWorker case relationship when 'follow' begin - FollowService.new.call(from_account, target_account, options) + FollowService.new.call(from_account, target_account, **options) rescue ActiveRecord::RecordInvalid raise if FollowLimitValidator.limit_for_account(from_account) < from_account.following_count end @@ -27,7 +27,7 @@ class Import::RelationshipWorker when 'unblock' UnblockService.new.call(from_account, target_account) when 'mute' - MuteService.new.call(from_account, target_account, options) + MuteService.new.call(from_account, target_account, **options) when 'unmute' UnmuteService.new.call(from_account, target_account) end diff --git a/config/application.rb b/config/application.rb index 37a996224..08a4e4c97 100644 --- a/config/application.rb +++ b/config/application.rb @@ -10,6 +10,7 @@ require_relative '../lib/exceptions' require_relative '../lib/enumerable' require_relative '../lib/sanitize_ext/sanitize_config' require_relative '../lib/redis/namespace_extensions' +require_relative '../lib/paperclip/validation_extensions' require_relative '../lib/paperclip/url_generator_extensions' require_relative '../lib/paperclip/attachment_extensions' require_relative '../lib/paperclip/media_type_spoof_detector_extensions' diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index e5d1be4c6..3d9bf96fd 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,7 +1,6 @@ # Be sure to restart your server when you modify this file. -Rails.application.config.session_store :cookie_store, { +Rails.application.config.session_store :cookie_store, key: '_mastodon_session', secure: (Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true'), - same_site: :lax, -} + same_site: :lax diff --git a/lib/paperclip/validation_extensions.rb b/lib/paperclip/validation_extensions.rb new file mode 100644 index 000000000..0df0434f6 --- /dev/null +++ b/lib/paperclip/validation_extensions.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Monkey-patch various Paperclip validators for Ruby 3.0 compatibility + +module Paperclip + module Validators + module AttachmentSizeValidatorExtensions + def validate_each(record, attr_name, _value) + base_attr_name = attr_name + attr_name = "#{attr_name}_file_size".to_sym + value = record.send(:read_attribute_for_validation, attr_name) + + if value.present? + options.slice(*Paperclip::Validators::AttachmentSizeValidator::AVAILABLE_CHECKS).each do |option, option_value| + option_value = option_value.call(record) if option_value.is_a?(Proc) + option_value = extract_option_value(option, option_value) + + next if value.send(Paperclip::Validators::AttachmentSizeValidator::CHECKS[option], option_value) + + error_message_key = options[:in] ? :in_between : option + [attr_name, base_attr_name].each do |error_attr_name| + record.errors.add(error_attr_name, error_message_key, **filtered_options(value).merge( + min: min_value_in_human_size(record), + max: max_value_in_human_size(record), + count: human_size(option_value) + )) + end + end + end + end + end + + module AttachmentContentTypeValidatorExtensions + def mark_invalid(record, attribute, types) + record.errors.add attribute, :invalid, **options.merge({ types: types.join(', ') }) + end + end + + module AttachmentPresenceValidatorExtensions + def validate_each(record, attribute, _value) + if record.send("#{attribute}_file_name").blank? + record.errors.add(attribute, :blank, **options) + end + end + end + + module AttachmentFileNameValidatorExtensions + def mark_invalid(record, attribute, patterns) + record.errors.add attribute, :invalid, options.merge({ names: patterns.join(', ') }) + end + end + end +end + +Paperclip::Validators::AttachmentSizeValidator.prepend(Paperclip::Validators::AttachmentSizeValidatorExtensions) +Paperclip::Validators::AttachmentContentTypeValidator.prepend(Paperclip::Validators::AttachmentContentTypeValidatorExtensions) +Paperclip::Validators::AttachmentPresenceValidator.prepend(Paperclip::Validators::AttachmentPresenceValidatorExtensions) +Paperclip::Validators::AttachmentFileNameValidator.prepend(Paperclip::Validators::AttachmentFileNameValidatorExtensions) diff --git a/lib/terrapin/multi_pipe_extensions.rb b/lib/terrapin/multi_pipe_extensions.rb index 51d7de37c..209f4ad6c 100644 --- a/lib/terrapin/multi_pipe_extensions.rb +++ b/lib/terrapin/multi_pipe_extensions.rb @@ -1,61 +1,64 @@ # frozen_string_literal: false -# Fix adapted from https://github.com/thoughtbot/terrapin/pull/5 + +require 'fcntl' module Terrapin module MultiPipeExtensions - def read - read_streams(@stdout_in, @stderr_in) - end + def initialize + @stdout_in, @stdout_out = IO.pipe + @stderr_in, @stderr_out = IO.pipe - def close_read - begin - @stdout_in.close - rescue IOError - # Do nothing - end - - begin - @stderr_in.close - rescue IOError - # Do nothing - end + clear_nonblocking_flags! end - def read_streams(output, error) - @stdout_output = '' - @stderr_output = '' + def pipe_options + # Add some flags to explicitly close the other end of the pipes + { out: @stdout_out, err: @stderr_out, @stdout_in => :close, @stderr_in => :close } + end - read_fds = [output, error] + def read + # While we are patching Terrapin, fix child process potentially getting stuck on writing + # to stderr. - until read_fds.empty? - to_read, = IO.select(read_fds) + @stdout_output = +'' + @stderr_output = +'' - if to_read.include?(output) - @stdout_output << read_stream(output) - read_fds.delete(output) if output.closed? - end + fds_to_read = [@stdout_in, @stderr_in] + until fds_to_read.empty? + rs, = IO.select(fds_to_read) - if to_read.include?(error) - @stderr_output << read_stream(error) - read_fds.delete(error) if error.closed? - end + read_nonblocking!(@stdout_in, @stdout_output, fds_to_read) if rs.include?(@stdout_in) + read_nonblocking!(@stderr_in, @stderr_output, fds_to_read) if rs.include?(@stderr_in) end end - def read_stream(io) - result = '' - - begin - while (partial_result = io.read_nonblock(8192)) - result << partial_result - end - rescue EOFError, Errno::EPIPE - io.close - rescue Errno::EINTR, Errno::EWOULDBLOCK, Errno::EAGAIN - # Do nothing + private + + # @param [IO] io IO Stream to read until there is nothing to read + # @param [String] result Mutable string to which read values will be appended to + # @param [Array] fds_to_read Mutable array from which `io` should be removed on EOF + def read_nonblocking!(io, result, fds_to_read) + while (partial_result = io.read_nonblock(8192)) + result << partial_result end + rescue IO::WaitReadable + # Do nothing + rescue EOFError + fds_to_read.delete(io) + end + + def clear_nonblocking_flags! + # Ruby 3.0 sets pipes to non-blocking mode, and resets the flags as + # needed when calling fork/exec-related syscalls, but posix-spawn does + # not currently do that, so we need to do it manually for the time being + # so that the child process do not error out when the buffers are full. + stdout_flags = @stdout_out.fcntl(Fcntl::F_GETFL) + @stdout_out.fcntl(Fcntl::F_SETFL, stdout_flags & ~Fcntl::O_NONBLOCK) if stdout_flags & Fcntl::O_NONBLOCK - result + stderr_flags = @stderr_out.fcntl(Fcntl::F_GETFL) + @stderr_out.fcntl(Fcntl::F_SETFL, stderr_flags & ~Fcntl::O_NONBLOCK) if stderr_flags & Fcntl::O_NONBLOCK + rescue NameError, NotImplementedError, Errno::EINVAL + # Probably on windows, where pipes are blocking by default end end end diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb index 73b50e721..7824854f9 100644 --- a/spec/controllers/admin/dashboard_controller_spec.rb +++ b/spec/controllers/admin/dashboard_controller_spec.rb @@ -3,9 +3,19 @@ require 'rails_helper' describe Admin::DashboardController, type: :controller do + render_views + describe 'GET #index' do - it 'returns 200' do + before do + allow(Admin::SystemCheck).to receive(:perform).and_return([ + Admin::SystemCheck::Message.new(:database_schema_check), + Admin::SystemCheck::Message.new(:rules_check, nil, admin_rules_path), + Admin::SystemCheck::Message.new(:sidekiq_process_check, 'foo, bar'), + ]) sign_in Fabricate(:user, admin: true) + end + + it 'returns 200' do get :index expect(response).to have_http_status(200) diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 3ae106218..9b645bad8 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -10,12 +10,12 @@ RSpec.describe NotificationMailer, type: :mailer do it 'renders subject localized for the locale of the receiver' do locale = %i(de en).sample receiver.update!(locale: locale) - expect(mail.subject).to eq I18n.t(*args, kwrest.merge(locale: locale)) + expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale)) end it 'renders subject localized for the default locale if the locale of the receiver is unavailable' do receiver.update!(locale: nil) - expect(mail.subject).to eq I18n.t(*args, kwrest.merge(locale: I18n.default_locale)) + expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: I18n.default_locale)) end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 6b430b505..9c866788f 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -9,12 +9,12 @@ describe UserMailer, type: :mailer do it 'renders subject localized for the locale of the receiver' do locale = I18n.available_locales.sample receiver.update!(locale: locale) - expect(mail.subject).to eq I18n.t(*args, kwrest.merge(locale: locale)) + expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale)) end it 'renders subject localized for the default locale if the locale of the receiver is unavailable' do receiver.update!(locale: nil) - expect(mail.subject).to eq I18n.t(*args, kwrest.merge(locale: I18n.default_locale)) + expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: I18n.default_locale)) end end diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb index 2aa695037..450dc1399 100644 --- a/spec/models/session_activation_spec.rb +++ b/spec/models/session_activation_spec.rb @@ -74,13 +74,13 @@ RSpec.describe SessionActivation, type: :model do let(:options) { { user: Fabricate(:user), session_id: '1' } } it 'calls create! and purge_old' do - expect(described_class).to receive(:create!).with(options) + expect(described_class).to receive(:create!).with(**options) expect(described_class).to receive(:purge_old) - described_class.activate(options) + described_class.activate(**options) end it 'returns an instance of SessionActivation' do - expect(described_class.activate(options)).to be_kind_of SessionActivation + expect(described_class.activate(**options)).to be_kind_of SessionActivation end end diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb index f8b048d38..edfbbb354 100644 --- a/spec/presenters/account_relationships_presenter_spec.rb +++ b/spec/presenters/account_relationships_presenter_spec.rb @@ -13,7 +13,7 @@ RSpec.describe AccountRelationshipsPresenter do allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map) end - let(:presenter) { AccountRelationshipsPresenter.new(account_ids, current_account_id, options) } + let(:presenter) { AccountRelationshipsPresenter.new(account_ids, current_account_id, **options) } let(:current_account_id) { Fabricate(:account).id } let(:account_ids) { [Fabricate(:account).id] } let(:default_map) { { 1 => true } } -- cgit