From 17f4e457b3a909522a230fd1f1f8f737e3faad87 Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Mon, 18 Oct 2021 19:02:35 +0900 Subject: Add remove from followers api (#16864) * Add followed_by? to account_interactions * Add RemoveFromFollowersService * Fix AccountBatch to use RemoveFromFollowersService * Add remove from followers API --- spec/controllers/api/v1/accounts_controller_spec.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) (limited to 'spec/controllers/api/v1') diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index d9ee37ffa..9a5a7c72a 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -168,6 +168,26 @@ RSpec.describe Api::V1::AccountsController, type: :controller do it_behaves_like 'forbidden for wrong scope', 'read:accounts' end + describe 'POST #remove_from_followers' do + let(:scopes) { 'write:follows' } + let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + other_account.follow!(user.account) + post :remove_from_followers, params: { id: other_account.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'removes the followed relation between user and target user' do + expect(user.account.followed_by?(other_account)).to be false + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + describe 'POST #block' do let(:scopes) { 'write:blocks' } let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } -- cgit From 87085a5152011b2f5595feba2a6c4d56a2b425f0 Mon Sep 17 00:00:00 2001 From: Claire Date: Sat, 6 Nov 2021 00:12:25 +0100 Subject: Fix AccountNote not having a maximum length (#16942) --- app/models/account_note.rb | 1 + app/workers/move_worker.rb | 8 +++- .../api/v1/accounts/notes_controller_spec.rb | 48 ++++++++++++++++++++++ spec/workers/move_worker_spec.rb | 41 +++++++++++++----- 4 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 spec/controllers/api/v1/accounts/notes_controller_spec.rb (limited to 'spec/controllers/api/v1') diff --git a/app/models/account_note.rb b/app/models/account_note.rb index bf61df923..b338bc92f 100644 --- a/app/models/account_note.rb +++ b/app/models/account_note.rb @@ -17,4 +17,5 @@ class AccountNote < ApplicationRecord belongs_to :target_account, class_name: 'Account' validates :account_id, uniqueness: { scope: :target_account_id } + validates :comment, length: { maximum: 2_000 } end diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index cc2c17d32..4a900e3b8 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -53,10 +53,16 @@ class MoveWorker new_note = AccountNote.find_by(account: note.account, target_account: @target_account) if new_note.nil? - AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join("\n")) + begin + AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join("\n")) + rescue ActiveRecord::RecordInvalid + AccountNote.create!(account: note.account, target_account: @target_account, comment: note.comment) + end else new_note.update!(comment: [text, note.comment, "\n", new_note.comment].join("\n")) end + rescue ActiveRecord::RecordInvalid + nil rescue => e @deferred_error = e end diff --git a/spec/controllers/api/v1/accounts/notes_controller_spec.rb b/spec/controllers/api/v1/accounts/notes_controller_spec.rb new file mode 100644 index 000000000..0a2957fed --- /dev/null +++ b/spec/controllers/api/v1/accounts/notes_controller_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe Api::V1::Accounts::NotesController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:accounts') } + let(:account) { Fabricate(:account) } + let(:comment) { 'foo' } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + subject do + post :create, params: { account_id: account.id, comment: comment } + end + + context 'when account note has reasonable length' do + let(:comment) { 'foo' } + + it 'returns http success' do + subject + expect(response).to have_http_status(200) + end + + it 'updates account note' do + subject + expect(AccountNote.find_by(account_id: user.account.id, target_account_id: account.id).comment).to eq comment + end + end + + context 'when account note exceends allowed length' do + let(:comment) { 'a' * 2_001 } + + it 'returns 422' do + subject + expect(response).to have_http_status(422) + end + + it 'does not create account note' do + subject + expect(AccountNote.where(account_id: user.account.id, target_account_id: account.id).exists?).to be_falsey + end + end + end +end diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb index 8ab4f182f..82449b0c7 100644 --- a/spec/workers/move_worker_spec.rb +++ b/spec/workers/move_worker_spec.rb @@ -9,7 +9,8 @@ describe MoveWorker do let(:source_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') } let(:target_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') } let(:local_user) { Fabricate(:user) } - let!(:account_note) { Fabricate(:account_note, account: local_user.account, target_account: source_account) } + let(:comment) { 'old note prior to move' } + let!(:account_note) { Fabricate(:account_note, account: local_user.account, target_account: source_account, comment: comment) } let(:block_service) { double } @@ -26,19 +27,37 @@ describe MoveWorker do end shared_examples 'user note handling' do - it 'copies user note' do - subject.perform(source_account.id, target_account.id) - expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct) - expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment) + context 'when user notes are short enough' do + it 'copies user note with prelude' do + subject.perform(source_account.id, target_account.id) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment) + end + + it 'merges user notes when needed' do + new_account_note = AccountNote.create!(account: account_note.account, target_account: target_account, comment: 'new note prior to move') + + subject.perform(source_account.id, target_account.id) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(new_account_note.comment) + end end - it 'merges user notes when needed' do - new_account_note = AccountNote.create!(account: account_note.account, target_account: target_account, comment: 'new note prior to move') + context 'when user notes are too long' do + let(:comment) { 'abc' * 333 } - subject.perform(source_account.id, target_account.id) - expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct) - expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment) - expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(new_account_note.comment) + it 'copies user note without prelude' do + subject.perform(source_account.id, target_account.id) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment) + end + + it 'keeps user notes unchanged' do + new_account_note = AccountNote.create!(account: account_note.account, target_account: target_account, comment: 'new note prior to move') + + subject.perform(source_account.id, target_account.id) + expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(new_account_note.comment) + end end end -- cgit From 6e50134a42cb303e6e42f89f9ddb5aacf83e7a6d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 25 Nov 2021 13:07:38 +0100 Subject: Add trending links (#16917) * Add trending links * Add overriding specific links trendability * Add link type to preview cards and only trend articles Change trends review notifications from being sent every 5 minutes to being sent every 2 hours Change threshold from 5 unique accounts to 15 unique accounts * Fix tests --- app/chewy/tags_index.rb | 2 +- app/controllers/admin/dashboard_controller.rb | 2 +- app/controllers/admin/tags_controller.rb | 76 +----------- .../links/preview_card_providers_controller.rb | 41 +++++++ app/controllers/admin/trends/links_controller.rb | 45 ++++++++ app/controllers/admin/trends/tags_controller.rb | 41 +++++++ .../api/v1/admin/dimensions_controller.rb | 3 +- .../api/v1/admin/measures_controller.rb | 3 +- .../api/v1/admin/trends/tags_controller.rb | 16 +++ app/controllers/api/v1/admin/trends_controller.rb | 16 --- app/controllers/api/v1/trends/links_controller.rb | 21 ++++ app/controllers/api/v1/trends/tags_controller.rb | 21 ++++ app/controllers/api/v1/trends_controller.rb | 15 --- app/helpers/admin/filter_helper.rb | 2 + app/helpers/languages_helper.rb | 94 +++++++++++++++ app/helpers/settings_helper.rb | 89 +------------- .../mastodon/components/admin/Counter.js | 5 +- .../mastodon/components/admin/Dimension.js | 5 +- app/javascript/mastodon/components/admin/Trends.js | 2 +- app/javascript/styles/mastodon/accounts.scss | 16 +++ app/javascript/styles/mastodon/dashboard.scss | 10 ++ app/lib/activitypub/activity.rb | 2 - app/lib/activitypub/activity/announce.rb | 5 +- app/lib/activitypub/activity/create.rb | 7 +- app/lib/admin/metrics/dimension.rb | 9 +- app/lib/admin/metrics/dimension/base_dimension.rb | 13 ++- .../admin/metrics/dimension/languages_dimension.rb | 4 +- .../metrics/dimension/tag_languages_dimension.rb | 36 ++++++ .../metrics/dimension/tag_servers_dimension.rb | 35 ++++++ app/lib/admin/metrics/measure.rb | 10 +- .../admin/metrics/measure/active_users_measure.rb | 4 +- app/lib/admin/metrics/measure/base_measure.rb | 15 ++- .../admin/metrics/measure/interactions_measure.rb | 4 +- .../admin/metrics/measure/tag_accounts_measure.rb | 41 +++++++ .../admin/metrics/measure/tag_servers_measure.rb | 47 ++++++++ app/lib/admin/metrics/measure/tag_uses_measure.rb | 41 +++++++ app/lib/link_details_extractor.rb | 49 +++++++- app/mailers/admin_mailer.rb | 22 +++- app/models/account_statuses_cleanup_policy.rb | 4 +- app/models/form/preview_card_batch.rb | 65 +++++++++++ app/models/form/preview_card_provider_batch.rb | 33 ++++++ app/models/form/tag_batch.rb | 8 +- app/models/preview_card.rb | 42 ++++++- app/models/preview_card_filter.rb | 53 +++++++++ app/models/preview_card_provider.rb | 57 +++++++++ app/models/preview_card_provider_filter.rb | 49 ++++++++ app/models/tag.rb | 23 +--- app/models/tag_filter.rb | 56 +++++---- app/models/trending_tags.rb | 128 --------------------- app/models/trends.rb | 27 +++++ app/models/trends/base.rb | 80 +++++++++++++ app/models/trends/history.rb | 98 ++++++++++++++++ app/models/trends/links.rb | 117 +++++++++++++++++++ app/models/trends/tags.rb | 111 ++++++++++++++++++ app/policies/preview_card_policy.rb | 11 ++ app/policies/preview_card_provider_policy.rb | 11 ++ app/serializers/rest/trends/link_serializer.rb | 5 + app/services/fetch_link_card_service.rb | 3 +- app/services/post_status_service.rb | 3 +- app/services/process_hashtags_service.rb | 2 +- app/services/reblog_service.rb | 13 +-- app/views/admin/dashboard/index.html.haml | 2 +- app/views/admin/tags/_tag.html.haml | 19 --- app/views/admin/tags/index.html.haml | 74 ------------ app/views/admin/tags/show.html.haml | 68 +++++++---- .../admin/trends/links/_preview_card.html.haml | 30 +++++ app/views/admin/trends/links/index.html.haml | 41 +++++++ .../_preview_card_provider.html.haml | 16 +++ .../links/preview_card_providers/index.html.haml | 43 +++++++ app/views/admin/trends/tags/_tag.html.haml | 24 ++++ app/views/admin/trends/tags/index.html.haml | 38 ++++++ app/views/admin_mailer/new_trending_links.text.erb | 16 +++ app/views/admin_mailer/new_trending_tag.text.erb | 5 - app/views/admin_mailer/new_trending_tags.text.erb | 16 +++ app/views/application/_sidebar.html.haml | 2 +- app/workers/scheduler/trending_tags_scheduler.rb | 11 -- app/workers/scheduler/trends/refresh_scheduler.rb | 11 ++ .../trends/review_notifications_scheduler.rb | 11 ++ config/brakeman.ignore | 112 +++++++++++++----- config/locales/en.yml | 73 +++++++++--- config/locales/simple_form.en.yml | 4 +- config/navigation.rb | 6 +- config/routes.rb | 36 ++++-- config/sidekiq.yml | 8 +- ...20211031031021_create_preview_card_providers.rb | 12 ++ ...20211112011713_add_language_to_preview_cards.rb | 7 ++ ...0211115032527_add_trendable_to_preview_cards.rb | 5 + ...0211123212714_add_link_type_to_preview_cards.rb | 5 + db/schema.rb | 21 +++- lib/mastodon/snowflake.rb | 5 +- lib/tasks/repo.rake | 2 +- spec/controllers/admin/tags_controller_spec.rb | 12 -- .../api/v1/trends/tags_controller_spec.rb | 22 ++++ spec/controllers/api/v1/trends_controller_spec.rb | 18 --- spec/helpers/languages_helper_spec.rb | 17 +++ spec/helpers/settings_helper_spec.rb | 22 ---- spec/mailers/previews/admin_mailer_preview.rb | 10 ++ spec/models/trending_tags_spec.rb | 68 ----------- spec/models/trends/tags_spec.rb | 67 +++++++++++ 99 files changed, 2088 insertions(+), 739 deletions(-) create mode 100644 app/controllers/admin/trends/links/preview_card_providers_controller.rb create mode 100644 app/controllers/admin/trends/links_controller.rb create mode 100644 app/controllers/admin/trends/tags_controller.rb create mode 100644 app/controllers/api/v1/admin/trends/tags_controller.rb delete mode 100644 app/controllers/api/v1/admin/trends_controller.rb create mode 100644 app/controllers/api/v1/trends/links_controller.rb create mode 100644 app/controllers/api/v1/trends/tags_controller.rb delete mode 100644 app/controllers/api/v1/trends_controller.rb create mode 100644 app/helpers/languages_helper.rb create mode 100644 app/lib/admin/metrics/dimension/tag_languages_dimension.rb create mode 100644 app/lib/admin/metrics/dimension/tag_servers_dimension.rb create mode 100644 app/lib/admin/metrics/measure/tag_accounts_measure.rb create mode 100644 app/lib/admin/metrics/measure/tag_servers_measure.rb create mode 100644 app/lib/admin/metrics/measure/tag_uses_measure.rb create mode 100644 app/models/form/preview_card_batch.rb create mode 100644 app/models/form/preview_card_provider_batch.rb create mode 100644 app/models/preview_card_filter.rb create mode 100644 app/models/preview_card_provider.rb create mode 100644 app/models/preview_card_provider_filter.rb delete mode 100644 app/models/trending_tags.rb create mode 100644 app/models/trends.rb create mode 100644 app/models/trends/base.rb create mode 100644 app/models/trends/history.rb create mode 100644 app/models/trends/links.rb create mode 100644 app/models/trends/tags.rb create mode 100644 app/policies/preview_card_policy.rb create mode 100644 app/policies/preview_card_provider_policy.rb create mode 100644 app/serializers/rest/trends/link_serializer.rb delete mode 100644 app/views/admin/tags/_tag.html.haml delete mode 100644 app/views/admin/tags/index.html.haml create mode 100644 app/views/admin/trends/links/_preview_card.html.haml create mode 100644 app/views/admin/trends/links/index.html.haml create mode 100644 app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml create mode 100644 app/views/admin/trends/links/preview_card_providers/index.html.haml create mode 100644 app/views/admin/trends/tags/_tag.html.haml create mode 100644 app/views/admin/trends/tags/index.html.haml create mode 100644 app/views/admin_mailer/new_trending_links.text.erb delete mode 100644 app/views/admin_mailer/new_trending_tag.text.erb create mode 100644 app/views/admin_mailer/new_trending_tags.text.erb delete mode 100644 app/workers/scheduler/trending_tags_scheduler.rb create mode 100644 app/workers/scheduler/trends/refresh_scheduler.rb create mode 100644 app/workers/scheduler/trends/review_notifications_scheduler.rb create mode 100644 db/migrate/20211031031021_create_preview_card_providers.rb create mode 100644 db/migrate/20211112011713_add_language_to_preview_cards.rb create mode 100644 db/migrate/20211115032527_add_trendable_to_preview_cards.rb create mode 100644 db/migrate/20211123212714_add_link_type_to_preview_cards.rb create mode 100644 spec/controllers/api/v1/trends/tags_controller_spec.rb delete mode 100644 spec/controllers/api/v1/trends_controller_spec.rb create mode 100644 spec/helpers/languages_helper_spec.rb delete mode 100644 spec/helpers/settings_helper_spec.rb delete mode 100644 spec/models/trending_tags_spec.rb create mode 100644 spec/models/trends/tags_spec.rb (limited to 'spec/controllers/api/v1') diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb index f811a8d67..f9db2b03a 100644 --- a/app/chewy/tags_index.rb +++ b/app/chewy/tags_index.rb @@ -31,7 +31,7 @@ class TagsIndex < Chewy::Index end field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? } - field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } } + field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day.accounts } } field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at } end end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index cbfff2707..f0a935411 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -4,7 +4,7 @@ module Admin class DashboardController < BaseController def index @system_checks = Admin::SystemCheck.perform - @time_period = (1.month.ago.to_date...Time.now.utc.to_date) + @time_period = (29.days.ago.to_date...Time.now.utc.to_date) @pending_users_count = User.pending.count @pending_reports_count = Report.unresolved.count @pending_tags_count = Tag.pending_review.count diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index eed4feea2..749e2f144 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -2,38 +2,12 @@ module Admin class TagsController < BaseController - before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all] - before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all] - before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all] - - def index - authorize :tag, :index? - - @tags = filtered_tags.page(params[:page]) - @form = Form::TagBatch.new - end - - def batch - @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button)) - @form.save - rescue ActionController::ParameterMissing - flash[:alert] = I18n.t('admin.accounts.no_account_selected') - ensure - redirect_to admin_tags_path(filter_params) - end - - def approve_all - Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save - redirect_to admin_tags_path(filter_params) - end - - def reject_all - Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save - redirect_to admin_tags_path(filter_params) - end + before_action :set_tag def show authorize @tag, :show? + + @time_period = (6.days.ago.to_date...Time.now.utc.to_date) end def update @@ -52,52 +26,8 @@ module Admin @tag = Tag.find(params[:id]) end - def set_usage_by_domain - @usage_by_domain = @tag.statuses - .with_public_visibility - .excluding_silenced_accounts - .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day))) - .joins(:account) - .group('accounts.domain') - .reorder(statuses_count: :desc) - .pluck(Arel.sql('accounts.domain, count(*) AS statuses_count')) - end - - def set_counters - @accounts_today = @tag.history.first[:accounts] - @accounts_week = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" }) - end - - def filtered_tags - TagFilter.new(filter_params).results - end - - def filter_params - params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) - end - def tag_params params.require(:tag).permit(:name, :trendable, :usable, :listable) end - - def current_week_days - now = Time.now.utc.beginning_of_day.to_date - - (Date.commercial(now.cwyear, now.cweek)..now).map do |date| - date.to_time(:utc).beginning_of_day.to_i - end - end - - def form_tag_batch_params - params.require(:form_tag_batch).permit(:action, tag_ids: []) - end - - def action_from_button - if params[:approve] - 'approve' - elsif params[:reject] - 'reject' - end - end end end diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb new file mode 100644 index 000000000..2c26e03f3 --- /dev/null +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController + def index + authorize :preview_card_provider, :index? + + @preview_card_providers = filtered_preview_card_providers.page(params[:page]) + @form = Form::PreviewCardProviderBatch.new + end + + def batch + @form = Form::PreviewCardProviderBatch.new(form_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_trends_links_preview_card_providers_path(filter_params) + end + + private + + def filtered_preview_card_providers + PreviewCardProviderFilter.new(filter_params).results + end + + def filter_params + params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS) + end + + def form_preview_card_provider_batch_params + params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) + end + + def action_from_button + if params[:approve] + 'approve' + elsif params[:reject] + 'reject' + end + end +end diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb new file mode 100644 index 000000000..619b37deb --- /dev/null +++ b/app/controllers/admin/trends/links_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Admin::Trends::LinksController < Admin::BaseController + def index + authorize :preview_card, :index? + + @preview_cards = filtered_preview_cards.page(params[:page]) + @form = Form::PreviewCardBatch.new + end + + def batch + @form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_trends_links_path(filter_params) + end + + private + + def filtered_preview_cards + PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results + end + + def filter_params + params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS) + end + + def form_preview_card_batch_params + params.require(:form_preview_card_batch).permit(:action, preview_card_ids: []) + end + + def action_from_button + if params[:approve] + 'approve' + elsif params[:approve_all] + 'approve_all' + elsif params[:reject] + 'reject' + elsif params[:reject_all] + 'reject_all' + end + end +end diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb new file mode 100644 index 000000000..91ff33d40 --- /dev/null +++ b/app/controllers/admin/trends/tags_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Admin::Trends::TagsController < Admin::BaseController + def index + authorize :tag, :index? + + @tags = filtered_tags.page(params[:page]) + @form = Form::TagBatch.new + end + + def batch + @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_trends_tags_path(filter_params) + end + + private + + def filtered_tags + TagFilter.new(filter_params).results + end + + def filter_params + params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) + end + + def form_tag_batch_params + params.require(:form_tag_batch).permit(:action, tag_ids: []) + end + + def action_from_button + if params[:approve] + 'approve' + elsif params[:reject] + 'reject' + end + end +end diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb index 170596d27..5e8f0f89f 100644 --- a/app/controllers/api/v1/admin/dimensions_controller.rb +++ b/app/controllers/api/v1/admin/dimensions_controller.rb @@ -17,7 +17,8 @@ class Api::V1::Admin::DimensionsController < Api::BaseController params[:keys], params[:start_at], params[:end_at], - params[:limit] + params[:limit], + params ) end end diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb index a3ac6fe85..f28191753 100644 --- a/app/controllers/api/v1/admin/measures_controller.rb +++ b/app/controllers/api/v1/admin/measures_controller.rb @@ -16,7 +16,8 @@ class Api::V1::Admin::MeasuresController < Api::BaseController @measures = Admin::Metrics::Measure.retrieve( params[:keys], params[:start_at], - params[:end_at] + params[:end_at], + params ) end end diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb new file mode 100644 index 000000000..3653d1dd1 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::Admin::Trends::TagsController < Api::BaseController + before_action :require_staff! + before_action :set_tags + + def index + render json: @tags, each_serializer: REST::Admin::TagSerializer + end + + private + + def set_tags + @tags = Trends.tags.get(false, limit_param(10)) + end +end diff --git a/app/controllers/api/v1/admin/trends_controller.rb b/app/controllers/api/v1/admin/trends_controller.rb deleted file mode 100644 index e32ab5d2c..000000000 --- a/app/controllers/api/v1/admin/trends_controller.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Admin::TrendsController < Api::BaseController - before_action :require_staff! - before_action :set_trends - - def index - render json: @trends, each_serializer: REST::Admin::TagSerializer - end - - private - - def set_trends - @trends = TrendingTags.get(10, filtered: false) - end -end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb new file mode 100644 index 000000000..1c3ab1e1c --- /dev/null +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::Trends::LinksController < Api::BaseController + before_action :set_links + + def index + render json: @links, each_serializer: REST::Trends::LinkSerializer + end + + private + + def set_links + @links = begin + if Setting.trends + Trends.links.get(true, limit_param(10)) + else + [] + end + end + end +end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb new file mode 100644 index 000000000..947b53de2 --- /dev/null +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::Trends::TagsController < Api::BaseController + before_action :set_tags + + def index + render json: @tags, each_serializer: REST::TagSerializer + end + + private + + def set_tags + @tags = begin + if Setting.trends + Trends.tags.get(true, limit_param(10)) + else + [] + end + end + end +end diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb deleted file mode 100644 index c875e9041..000000000 --- a/app/controllers/api/v1/trends_controller.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::TrendsController < Api::BaseController - before_action :set_tags - - def index - render json: @tags, each_serializer: REST::TagSerializer - end - - private - - def set_tags - @tags = TrendingTags.get(limit_param(10)) - end -end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index ba0ca9638..5f69f176a 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -6,6 +6,8 @@ module Admin::FilterHelper CustomEmojiFilter::KEYS, ReportFilter::KEYS, TagFilter::KEYS, + PreviewCardProviderFilter::KEYS, + PreviewCardFilter::KEYS, InstanceFilter::KEYS, InviteFilter::KEYS, RelationshipFilter::KEYS, diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb new file mode 100644 index 000000000..730724208 --- /dev/null +++ b/app/helpers/languages_helper.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module LanguagesHelper + HUMAN_LOCALES = { + af: 'Afrikaans', + ar: 'العربية', + ast: 'Asturianu', + bg: 'Български', + bn: 'বাংলা', + br: 'Breton', + ca: 'Català', + co: 'Corsu', + cs: 'Čeština', + cy: 'Cymraeg', + da: 'Dansk', + de: 'Deutsch', + el: 'Ελληνικά', + en: 'English', + eo: 'Esperanto', + 'es-AR': 'Español (Argentina)', + 'es-MX': 'Español (México)', + es: 'Español', + et: 'Eesti', + eu: 'Euskara', + fa: 'فارسی', + fi: 'Suomi', + fr: 'Français', + ga: 'Gaeilge', + gd: 'Gàidhlig', + gl: 'Galego', + he: 'עברית', + hi: 'हिन्दी', + hr: 'Hrvatski', + hu: 'Magyar', + hy: 'Հայերեն', + id: 'Bahasa Indonesia', + io: 'Ido', + is: 'Íslenska', + it: 'Italiano', + ja: '日本語', + ka: 'ქართული', + kab: 'Taqbaylit', + kk: 'Қазақша', + kmr: 'Kurmancî', + kn: 'ಕನ್ನಡ', + ko: '한국어', + ku: 'سۆرانی', + lt: 'Lietuvių', + lv: 'Latviešu', + mk: 'Македонски', + ml: 'മലയാളം', + mr: 'मराठी', + ms: 'Bahasa Melayu', + nl: 'Nederlands', + nn: 'Nynorsk', + no: 'Norsk', + oc: 'Occitan', + pl: 'Polski', + 'pt-BR': 'Português (Brasil)', + 'pt-PT': 'Português (Portugal)', + pt: 'Português', + ro: 'Română', + ru: 'Русский', + sa: 'संस्कृतम्', + sc: 'Sardu', + si: 'සිංහල', + sk: 'Slovenčina', + sl: 'Slovenščina', + sq: 'Shqip', + 'sr-Latn': 'Srpski (latinica)', + sr: 'Српски', + sv: 'Svenska', + ta: 'தமிழ்', + te: 'తెలుగు', + th: 'ไทย', + tr: 'Türkçe', + uk: 'Українська', + ur: 'اُردُو', + vi: 'Tiếng Việt', + zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ', + 'zh-CN': '简体中文', + 'zh-HK': '繁體中文(香港)', + 'zh-TW': '繁體中文(臺灣)', + zh: '中文', + }.freeze + + def human_locale(locale) + if locale == 'und' + I18n.t('generic.none') + else + HUMAN_LOCALES[locale.to_sym] || locale + end + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index ac4c18746..23739d1cd 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -1,95 +1,8 @@ # frozen_string_literal: true module SettingsHelper - HUMAN_LOCALES = { - af: 'Afrikaans', - ar: 'العربية', - ast: 'Asturianu', - bg: 'Български', - bn: 'বাংলা', - br: 'Breton', - ca: 'Català', - co: 'Corsu', - cs: 'Čeština', - cy: 'Cymraeg', - da: 'Dansk', - de: 'Deutsch', - el: 'Ελληνικά', - en: 'English', - eo: 'Esperanto', - 'es-AR': 'Español (Argentina)', - 'es-MX': 'Español (México)', - es: 'Español', - et: 'Eesti', - eu: 'Euskara', - fa: 'فارسی', - fi: 'Suomi', - fr: 'Français', - ga: 'Gaeilge', - gd: 'Gàidhlig', - gl: 'Galego', - he: 'עברית', - hi: 'हिन्दी', - hr: 'Hrvatski', - hu: 'Magyar', - hy: 'Հայերեն', - id: 'Bahasa Indonesia', - io: 'Ido', - is: 'Íslenska', - it: 'Italiano', - ja: '日本語', - ka: 'ქართული', - kab: 'Taqbaylit', - kk: 'Қазақша', - kmr: 'Kurmancî', - kn: 'ಕನ್ನಡ', - ko: '한국어', - ku: 'سۆرانی', - lt: 'Lietuvių', - lv: 'Latviešu', - mk: 'Македонски', - ml: 'മലയാളം', - mr: 'मराठी', - ms: 'Bahasa Melayu', - nl: 'Nederlands', - nn: 'Nynorsk', - no: 'Norsk', - oc: 'Occitan', - pl: 'Polski', - 'pt-BR': 'Português (Brasil)', - 'pt-PT': 'Português (Portugal)', - pt: 'Português', - ro: 'Română', - ru: 'Русский', - sa: 'संस्कृतम्', - sc: 'Sardu', - si: 'සිංහල', - sk: 'Slovenčina', - sl: 'Slovenščina', - sq: 'Shqip', - 'sr-Latn': 'Srpski (latinica)', - sr: 'Српски', - sv: 'Svenska', - ta: 'தமிழ்', - te: 'తెలుగు', - th: 'ไทย', - tr: 'Türkçe', - uk: 'Українська', - ur: 'اُردُو', - vi: 'Tiếng Việt', - zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ', - 'zh-CN': '简体中文', - 'zh-HK': '繁體中文(香港)', - 'zh-TW': '繁體中文(臺灣)', - zh: '中文', - }.freeze - - def human_locale(locale) - HUMAN_LOCALES[locale] - end - def filterable_languages - LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?)) + LanguageDetector.instance.language_names.select(&LanguagesHelper::HUMAN_LOCALES.method(:key?)) end def hash_to_object(hash) diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js index cda572dce..047e864b2 100644 --- a/app/javascript/mastodon/components/admin/Counter.js +++ b/app/javascript/mastodon/components/admin/Counter.js @@ -32,6 +32,7 @@ export default class Counter extends React.PureComponent { end_at: PropTypes.string.isRequired, label: PropTypes.string.isRequired, href: PropTypes.string, + params: PropTypes.object, }; state = { @@ -40,9 +41,9 @@ export default class Counter extends React.PureComponent { }; componentDidMount () { - const { measure, start_at, end_at } = this.props; + const { measure, start_at, end_at, params } = this.props; - api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => { + api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => { this.setState({ loading: false, data: res.data, diff --git a/app/javascript/mastodon/components/admin/Dimension.js b/app/javascript/mastodon/components/admin/Dimension.js index ac6dbd1c7..977c8208d 100644 --- a/app/javascript/mastodon/components/admin/Dimension.js +++ b/app/javascript/mastodon/components/admin/Dimension.js @@ -13,6 +13,7 @@ export default class Dimension extends React.PureComponent { end_at: PropTypes.string.isRequired, limit: PropTypes.number.isRequired, label: PropTypes.string.isRequired, + params: PropTypes.object, }; state = { @@ -21,9 +22,9 @@ export default class Dimension extends React.PureComponent { }; componentDidMount () { - const { start_at, end_at, dimension, limit } = this.props; + const { start_at, end_at, dimension, limit, params } = this.props; - api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => { + api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => { this.setState({ loading: false, data: res.data, diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js index 46307a28a..635bdf37d 100644 --- a/app/javascript/mastodon/components/admin/Trends.js +++ b/app/javascript/mastodon/components/admin/Trends.js @@ -19,7 +19,7 @@ export default class Trends extends React.PureComponent { componentDidMount () { const { limit } = this.props; - api().get('/api/v1/admin/trends', { params: { limit } }).then(res => { + api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => { this.setState({ loading: false, data: res.data, diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 2c78e81be..b8a6c8018 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -325,3 +325,19 @@ margin-top: 10px; } } + +.batch-table__row--muted .pending-account__header { + &, + a, + strong { + color: lighten($ui-base-color, 26%); + } +} + +.batch-table__row--attention .pending-account__header { + &, + a, + strong { + color: $gold-star; + } +} diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss index 5e900e8c5..0a881bc10 100644 --- a/app/javascript/styles/mastodon/dashboard.scss +++ b/app/javascript/styles/mastodon/dashboard.scss @@ -100,6 +100,16 @@ transition: all 200ms ease-out; } + &.positive { + background: lighten($ui-base-color, 4%); + color: $valid-value-color; + } + + &.negative { + background: lighten($ui-base-color, 4%); + color: $error-value-color; + } + span { flex: 1 1 auto; } diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index d2ec122a4..3aeecb4ec 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -129,8 +129,6 @@ class ActivityPub::Activity end def crawl_links(status) - return if status.spoiler_text? - # Spread out crawling randomly to avoid DDoSing the link LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id) end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 9f778ffb9..6c5d88d18 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -22,9 +22,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity visibility: visibility_from_audience ) - original_status.tags.each do |tag| - tag.use!(@account) - end + Trends.tags.register(@status) + Trends.links.register(@status) distribute(@status) end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4c13a80a6..8a0dc9d33 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -164,9 +164,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def attach_tags(status) @tags.each do |tag| status.tags << tag - tag.use!(@account, status: status, at_time: status.created_at) if status.public_visibility? + tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago) end + # If we're processing an old status, this may register tags as being used now + # as opposed to when the status was really published, but this is probably + # not a big deal + Trends.tags.register(status) + @mentions.each do |mention| mention.status = status mention.save diff --git a/app/lib/admin/metrics/dimension.rb b/app/lib/admin/metrics/dimension.rb index 279539f68..d8392ddfc 100644 --- a/app/lib/admin/metrics/dimension.rb +++ b/app/lib/admin/metrics/dimension.rb @@ -7,9 +7,14 @@ class Admin::Metrics::Dimension servers: Admin::Metrics::Dimension::ServersDimension, space_usage: Admin::Metrics::Dimension::SpaceUsageDimension, software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension, + tag_servers: Admin::Metrics::Dimension::TagServersDimension, + tag_languages: Admin::Metrics::Dimension::TagLanguagesDimension, }.freeze - def self.retrieve(dimension_keys, start_at, end_at, limit) - Array(dimension_keys).map { |key| DIMENSIONS[key.to_sym]&.new(start_at, end_at, limit) }.compact + def self.retrieve(dimension_keys, start_at, end_at, limit, params) + Array(dimension_keys).map do |key| + klass = DIMENSIONS[key.to_sym] + klass&.new(start_at, end_at, limit, klass.with_params? ? params.require(key.to_sym) : nil) + end.compact end end diff --git a/app/lib/admin/metrics/dimension/base_dimension.rb b/app/lib/admin/metrics/dimension/base_dimension.rb index 8ed8d7683..5872c22cb 100644 --- a/app/lib/admin/metrics/dimension/base_dimension.rb +++ b/app/lib/admin/metrics/dimension/base_dimension.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true class Admin::Metrics::Dimension::BaseDimension - def initialize(start_at, end_at, limit) + def self.with_params? + false + end + + def initialize(start_at, end_at, limit, params) @start_at = start_at&.to_datetime @end_at = end_at&.to_datetime @limit = limit&.to_i + @params = params end def key @@ -26,6 +31,10 @@ class Admin::Metrics::Dimension::BaseDimension protected def time_period - (@start_at...@end_at) + (@start_at..@end_at) + end + + def params + raise NotImplementedError end end diff --git a/app/lib/admin/metrics/dimension/languages_dimension.rb b/app/lib/admin/metrics/dimension/languages_dimension.rb index 2d0ac124e..a6aaf5d21 100644 --- a/app/lib/admin/metrics/dimension/languages_dimension.rb +++ b/app/lib/admin/metrics/dimension/languages_dimension.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension + include LanguagesHelper + def key 'languages' end @@ -18,6 +20,6 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension: rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]]) - rows.map { |row| { key: row['locale'], human_key: SettingsHelper::HUMAN_LOCALES[row['locale'].to_sym], value: row['value'].to_s } } + rows.map { |row| { key: row['locale'], human_key: human_locale(row['locale']), value: row['value'].to_s } } end end diff --git a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb new file mode 100644 index 000000000..1cfa07478 --- /dev/null +++ b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension + include LanguagesHelper + + def self.with_params? + true + end + + def key + 'tag_languages' + end + + def data + sql = <<-SQL.squish + SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value + FROM statuses + INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id + WHERE statuses_tags.tag_id = $1 + AND statuses.id BETWEEN $2 AND $3 + GROUP BY COALESCE(statuses.language, 'und') + ORDER BY count(*) DESC + LIMIT $4 + SQL + + rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) + + rows.map { |row| { key: row['language'], human_key: human_locale(row['language']), value: row['value'].to_s } } + end + + private + + def params + @params.permit(:id) + end +end diff --git a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb new file mode 100644 index 000000000..12c5980d7 --- /dev/null +++ b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension + def self.with_params? + true + end + + def key + 'tag_servers' + end + + def data + sql = <<-SQL.squish + SELECT accounts.domain, count(*) AS value + FROM statuses + INNER JOIN accounts ON accounts.id = statuses.account_id + INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id + WHERE statuses_tags.tag_id = $1 + AND statuses.id BETWEEN $2 AND $3 + GROUP BY accounts.domain + ORDER BY count(*) DESC + LIMIT $4 + SQL + + rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) + + rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } } + end + + private + + def params + @params.permit(:id) + end +end diff --git a/app/lib/admin/metrics/measure.rb b/app/lib/admin/metrics/measure.rb index 5cebf0331..a839498a1 100644 --- a/app/lib/admin/metrics/measure.rb +++ b/app/lib/admin/metrics/measure.rb @@ -7,9 +7,15 @@ class Admin::Metrics::Measure interactions: Admin::Metrics::Measure::InteractionsMeasure, opened_reports: Admin::Metrics::Measure::OpenedReportsMeasure, resolved_reports: Admin::Metrics::Measure::ResolvedReportsMeasure, + tag_accounts: Admin::Metrics::Measure::TagAccountsMeasure, + tag_uses: Admin::Metrics::Measure::TagUsesMeasure, + tag_servers: Admin::Metrics::Measure::TagServersMeasure, }.freeze - def self.retrieve(measure_keys, start_at, end_at) - Array(measure_keys).map { |key| MEASURES[key.to_sym]&.new(start_at, end_at) }.compact + def self.retrieve(measure_keys, start_at, end_at, params) + Array(measure_keys).map do |key| + klass = MEASURES[key.to_sym] + klass&.new(start_at, end_at, klass.with_params? ? params.require(key.to_sym) : nil) + end.compact end end diff --git a/app/lib/admin/metrics/measure/active_users_measure.rb b/app/lib/admin/metrics/measure/active_users_measure.rb index ac022eb9d..513189780 100644 --- a/app/lib/admin/metrics/measure/active_users_measure.rb +++ b/app/lib/admin/metrics/measure/active_users_measure.rb @@ -24,10 +24,10 @@ class Admin::Metrics::Measure::ActiveUsersMeasure < Admin::Metrics::Measure::Bas end def time_period - (@start_at.to_date...@end_at.to_date) + (@start_at.to_date..@end_at.to_date) end def previous_time_period - ((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period)) + ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period)) end end diff --git a/app/lib/admin/metrics/measure/base_measure.rb b/app/lib/admin/metrics/measure/base_measure.rb index 4c336a69e..0107ffd9c 100644 --- a/app/lib/admin/metrics/measure/base_measure.rb +++ b/app/lib/admin/metrics/measure/base_measure.rb @@ -1,9 +1,14 @@ # frozen_string_literal: true class Admin::Metrics::Measure::BaseMeasure - def initialize(start_at, end_at) + def self.with_params? + false + end + + def initialize(start_at, end_at, params) @start_at = start_at&.to_datetime @end_at = end_at&.to_datetime + @params = params end def key @@ -33,14 +38,18 @@ class Admin::Metrics::Measure::BaseMeasure protected def time_period - (@start_at...@end_at) + (@start_at..@end_at) end def previous_time_period - ((@start_at - length_of_period)...(@end_at - length_of_period)) + ((@start_at - length_of_period)..(@end_at - length_of_period)) end def length_of_period @length_of_period ||= @end_at - @start_at end + + def params + raise NotImplementedError + end end diff --git a/app/lib/admin/metrics/measure/interactions_measure.rb b/app/lib/admin/metrics/measure/interactions_measure.rb index 9a4ef6d63..b928fdb8f 100644 --- a/app/lib/admin/metrics/measure/interactions_measure.rb +++ b/app/lib/admin/metrics/measure/interactions_measure.rb @@ -24,10 +24,10 @@ class Admin::Metrics::Measure::InteractionsMeasure < Admin::Metrics::Measure::Ba end def time_period - (@start_at.to_date...@end_at.to_date) + (@start_at.to_date..@end_at.to_date) end def previous_time_period - ((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period)) + ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period)) end end diff --git a/app/lib/admin/metrics/measure/tag_accounts_measure.rb b/app/lib/admin/metrics/measure/tag_accounts_measure.rb new file mode 100644 index 000000000..ef773081b --- /dev/null +++ b/app/lib/admin/metrics/measure/tag_accounts_measure.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::TagAccountsMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'tag_accounts' + end + + def total + tag.history.aggregate(time_period).accounts + end + + def previous_total + tag.history.aggregate(previous_time_period).accounts + end + + def data + time_period.map { |date| { date: date.to_time(:utc).iso8601, value: tag.history.get(date).accounts.to_s } } + end + + protected + + def tag + @tag ||= Tag.find(params[:id]) + end + + def time_period + (@start_at.to_date..@end_at.to_date) + end + + def previous_time_period + ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period)) + end + + def params + @params.permit(:id) + end +end diff --git a/app/lib/admin/metrics/measure/tag_servers_measure.rb b/app/lib/admin/metrics/measure/tag_servers_measure.rb new file mode 100644 index 000000000..8c3e0551a --- /dev/null +++ b/app/lib/admin/metrics/measure/tag_servers_measure.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'tag_servers' + end + + def total + tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at, with_random: false), Mastodon::Snowflake.id_at(@end_at, with_random: false)).joins(:account).count('distinct accounts.domain') + end + + def previous_total + tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at - length_of_period, with_random: false), Mastodon::Snowflake.id_at(@end_at - length_of_period, with_random: false)).joins(:account).count('distinct accounts.domain') + end + + def data + sql = <<-SQL.squish + SELECT axis.*, ( + SELECT count(*) AS value + FROM statuses + WHERE statuses.id BETWEEN $1 AND $2 + AND date_trunc('day', statuses.created_at)::date = axis.day + ) + FROM ( + SELECT generate_series(date_trunc('day', $3::timestamp)::date, date_trunc('day', $4::timestamp)::date, ('1 day')::interval) AS day + ) as axis + SQL + + rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @start_at], [nil, @end_at]]) + + rows.map { |row| { date: row['day'], value: row['value'].to_s } } + end + + protected + + def tag + @tag ||= Tag.find(params[:id]) + end + + def params + @params.permit(:id) + end +end diff --git a/app/lib/admin/metrics/measure/tag_uses_measure.rb b/app/lib/admin/metrics/measure/tag_uses_measure.rb new file mode 100644 index 000000000..b7667bc6c --- /dev/null +++ b/app/lib/admin/metrics/measure/tag_uses_measure.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::TagUsesMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'tag_uses' + end + + def total + tag.history.aggregate(time_period).uses + end + + def previous_total + tag.history.aggregate(previous_time_period).uses + end + + def data + time_period.map { |date| { date: date.to_time(:utc).iso8601, value: tag.history.get(date).uses.to_s } } + end + + protected + + def tag + @tag ||= Tag.find(params[:id]) + end + + def time_period + (@start_at.to_date..@end_at.to_date) + end + + def previous_time_period + ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period)) + end + + def params + @params.permit(:id) + end +end diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb index 8b38e8d0c..56ad0717b 100644 --- a/app/lib/link_details_extractor.rb +++ b/app/lib/link_details_extractor.rb @@ -4,6 +4,11 @@ class LinkDetailsExtractor include ActionView::Helpers::TagHelper class StructuredData + SUPPORTED_TYPES = %w( + NewsArticle + WebPage + ).freeze + def initialize(data) @data = data end @@ -16,6 +21,14 @@ class LinkDetailsExtractor json['description'] end + def language + json['inLanguage'] + end + + def type + json['@type'] + end + def image obj = first_of_value(json['image']) @@ -44,6 +57,10 @@ class LinkDetailsExtractor publisher['name'] end + def publisher_logo + publisher.dig('logo', 'url') + end + private def author @@ -58,8 +75,12 @@ class LinkDetailsExtractor arr.is_a?(Array) ? arr.first : arr end + def root_array(root) + root.is_a?(Array) ? root : [root] + end + def json - @json ||= first_of_value(Oj.load(@data)) + @json ||= root_array(Oj.load(@data)).find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {} end end @@ -75,6 +96,7 @@ class LinkDetailsExtractor description: description || '', image_remote_url: image, type: type, + link_type: link_type, width: width || 0, height: height || 0, html: html || '', @@ -83,6 +105,7 @@ class LinkDetailsExtractor author_name: author_name || '', author_url: author_url || '', embed_url: embed_url || '', + language: language, } end @@ -90,6 +113,14 @@ class LinkDetailsExtractor player_url.present? ? :video : :link end + def link_type + if structured_data&.type == 'NewsArticle' || opengraph_tag('og:type') == 'article' + :article + else + :unknown + end + end + def html player_url.present? ? content_tag(:iframe, nil, src: player_url, width: width, height: height, allowtransparency: 'true', scrolling: 'no', frameborder: '0') : nil end @@ -138,6 +169,14 @@ class LinkDetailsExtractor valid_url_or_nil(opengraph_tag('twitter:player:stream')) end + def language + valid_locale_or_nil(structured_data&.language || opengraph_tag('og:locale') || document.xpath('//html').map { |element| element['lang'] }.first) + end + + def icon + valid_url_or_nil(structured_data&.publisher_icon || link_tag('apple-touch-icon') || link_tag('shortcut icon')) + end + private def player_url @@ -162,6 +201,14 @@ class LinkDetailsExtractor nil end + def valid_locale_or_nil(str) + return nil if str.blank? + + code, = str.split(/_-/) # Strip out the region from e.g. en_US or ja-JA + locale = ISO_639.find(code) + locale&.alpha2 + end + def link_tag(name) document.xpath("//link[@rel=\"#{name}\"]").map { |link| link['href'] }.first end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 11fd09e30..0fbd9932d 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -25,13 +25,25 @@ class AdminMailer < ApplicationMailer end end - def new_trending_tag(recipient, tag) - @tag = tag - @me = recipient - @instance = Rails.configuration.x.local_domain + def new_trending_tags(recipient, tags) + @tags = tags + @me = recipient + @instance = Rails.configuration.x.local_domain + @lowest_trending_tag = Trends.tags.get(true, Trends::Tags::REVIEW_THRESHOLD).last + + locale_for_account(@me) do + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tags.subject', instance: @instance) + end + end + + def new_trending_links(recipient, links) + @links = links + @me = recipient + @instance = Rails.configuration.x.local_domain + @lowest_trending_link = Trends.links.get(true, Trends::Links::REVIEW_THRESHOLD).last locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tag.subject', instance: @instance, name: @tag.name) + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance) end end end diff --git a/app/models/account_statuses_cleanup_policy.rb b/app/models/account_statuses_cleanup_policy.rb index 0a9551ec2..0f78c1a54 100644 --- a/app/models/account_statuses_cleanup_policy.rb +++ b/app/models/account_statuses_cleanup_policy.rb @@ -4,8 +4,8 @@ # # Table name: account_statuses_cleanup_policies # -# id :bigint not null, primary key -# account_id :bigint not null +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null # enabled :boolean default(TRUE), not null # min_status_age :integer default(1209600), not null # keep_direct :boolean default(TRUE), not null diff --git a/app/models/form/preview_card_batch.rb b/app/models/form/preview_card_batch.rb new file mode 100644 index 000000000..5f6e6522a --- /dev/null +++ b/app/models/form/preview_card_batch.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Form::PreviewCardBatch + include ActiveModel::Model + include Authorization + + attr_accessor :preview_card_ids, :action, :current_account, :precision + + def save + case action + when 'approve' + approve! + when 'approve_all' + approve_all! + when 'reject' + reject! + when 'reject_all' + reject_all! + end + end + + private + + def preview_cards + @preview_cards ||= PreviewCard.where(id: preview_card_ids) + end + + def preview_card_providers + @preview_card_providers ||= preview_cards.map(&:domain).uniq.map { |domain| PreviewCardProvider.matching_domain(domain) || PreviewCardProvider.new(domain: domain) } + end + + def approve! + preview_cards.each { |preview_card| authorize(preview_card, :update?) } + preview_cards.update_all(trendable: true) + end + + def approve_all! + preview_card_providers.each do |provider| + authorize(provider, :update?) + provider.update(trendable: true, reviewed_at: action_time) + end + + # Reset any individual overrides + preview_cards.update_all(trendable: nil) + end + + def reject! + preview_cards.each { |preview_card| authorize(preview_card, :update?) } + preview_cards.update_all(trendable: false) + end + + def reject_all! + preview_card_providers.each do |provider| + authorize(provider, :update?) + provider.update(trendable: false, reviewed_at: action_time) + end + + # Reset any individual overrides + preview_cards.update_all(trendable: nil) + end + + def action_time + @action_time ||= Time.now.utc + end +end diff --git a/app/models/form/preview_card_provider_batch.rb b/app/models/form/preview_card_provider_batch.rb new file mode 100644 index 000000000..e6ab3d8fa --- /dev/null +++ b/app/models/form/preview_card_provider_batch.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Form::PreviewCardProviderBatch + include ActiveModel::Model + include Authorization + + attr_accessor :preview_card_provider_ids, :action, :current_account + + def save + case action + when 'approve' + approve! + when 'reject' + reject! + end + end + + private + + def preview_card_providers + PreviewCardProvider.where(id: preview_card_provider_ids) + end + + def approve! + preview_card_providers.each { |provider| authorize(provider, :update?) } + preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc) + end + + def reject! + preview_card_providers.each { |provider| authorize(provider, :update?) } + preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc) + end +end diff --git a/app/models/form/tag_batch.rb b/app/models/form/tag_batch.rb index fd517a1a6..b9330745f 100644 --- a/app/models/form/tag_batch.rb +++ b/app/models/form/tag_batch.rb @@ -23,11 +23,15 @@ class Form::TagBatch def approve! tags.each { |tag| authorize(tag, :update?) } - tags.update_all(trendable: true, reviewed_at: Time.now.utc) + tags.update_all(trendable: true, reviewed_at: action_time) end def reject! tags.each { |tag| authorize(tag, :update?) } - tags.update_all(trendable: false, reviewed_at: Time.now.utc) + tags.update_all(trendable: false, reviewed_at: action_time) + end + + def action_time + @action_time ||= Time.now.utc end end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index bca3a3ce8..f2ab8ecab 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -24,6 +24,11 @@ # embed_url :string default(""), not null # image_storage_schema_version :integer # blurhash :string +# language :string +# max_score :float +# max_score_at :datetime +# trendable :boolean +# link_type :integer # class PreviewCard < ApplicationRecord @@ -40,6 +45,7 @@ class PreviewCard < ApplicationRecord self.inheritance_column = false enum type: [:link, :photo, :video, :rich] + enum link_type: [:unknown, :article] has_and_belongs_to_many :statuses @@ -54,6 +60,32 @@ class PreviewCard < ApplicationRecord before_save :extract_dimensions, if: :link? + def appropriate_for_trends? + link? && article? && title.present? && description.present? && image.present? && provider_name.present? + end + + def domain + @domain ||= Addressable::URI.parse(url).normalized_host + end + + def provider + @provider ||= PreviewCardProvider.matching_domain(domain) + end + + def trendable? + if attributes['trendable'].nil? + provider&.trendable? + else + attributes['trendable'] + end + end + + def requires_review_notification? + attributes['trendable'].nil? && (provider.nil? || provider.requires_review_notification?) + end + + attr_writer :provider + def local? false end @@ -69,11 +101,14 @@ class PreviewCard < ApplicationRecord save! end + def history + @history ||= Trends::History.new('links', id) + end + class << self private - # rubocop:disable Naming/MethodParameterName - def image_styles(f) + def image_styles(file) styles = { original: { geometry: '400x400>', @@ -83,10 +118,9 @@ class PreviewCard < ApplicationRecord }, } - styles[:original][:format] = 'jpg' if f.instance.image_content_type == 'image/gif' + styles[:original][:format] = 'jpg' if file.instance.image_content_type == 'image/gif' styles end - # rubocop:enable Naming/MethodParameterName end private diff --git a/app/models/preview_card_filter.rb b/app/models/preview_card_filter.rb new file mode 100644 index 000000000..8dda9989c --- /dev/null +++ b/app/models/preview_card_filter.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class PreviewCardFilter + KEYS = %i( + trending + ).freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = PreviewCard.unscoped + + params.each do |key, value| + next if key.to_s == 'page' + + scope.merge!(scope_for(key, value.to_s.strip)) if value.present? + end + + scope + end + + private + + def scope_for(key, value) + case key.to_s + when 'trending' + trending_scope(value) + else + raise "Unknown filter: #{key}" + end + end + + def trending_scope(value) + ids = begin + case value.to_s + when 'allowed' + Trends.links.currently_trending_ids(true, -1) + else + Trends.links.currently_trending_ids(false, -1) + end + end + + if ids.empty? + PreviewCard.none + else + PreviewCard.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id").order('x.ordering') + end + end +end diff --git a/app/models/preview_card_provider.rb b/app/models/preview_card_provider.rb new file mode 100644 index 000000000..15b24e2bd --- /dev/null +++ b/app/models/preview_card_provider.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: preview_card_providers +# +# id :bigint(8) not null, primary key +# domain :string default(""), not null +# icon_file_name :string +# icon_content_type :string +# icon_file_size :bigint(8) +# icon_updated_at :datetime +# trendable :boolean +# reviewed_at :datetime +# requested_review_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# + +class PreviewCardProvider < ApplicationRecord + include DomainNormalizable + include Attachmentable + + ICON_MIME_TYPES = %w(image/x-icon image/vnd.microsoft.icon image/png).freeze + LIMIT = 1.megabyte + + validates :domain, presence: true, uniqueness: true, domain: true + + has_attached_file :icon, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }, validate_media_type: false + validates_attachment :icon, content_type: { content_type: ICON_MIME_TYPES }, size: { less_than: LIMIT } + remotable_attachment :icon, LIMIT + + scope :trendable, -> { where(trendable: true) } + scope :not_trendable, -> { where(trendable: false) } + scope :reviewed, -> { where.not(reviewed_at: nil) } + scope :pending_review, -> { where(reviewed_at: nil) } + + def requires_review? + reviewed_at.nil? + end + + def reviewed? + reviewed_at.present? + end + + def requested_review? + requested_review_at.present? + end + + def requires_review_notification? + requires_review? && !requested_review? + end + + def self.matching_domain(domain) + segments = domain.split('.') + where(domain: segments.map.with_index { |_, i| segments[i..-1].join('.') }).order(Arel.sql('char_length(domain) desc')).first + end +end diff --git a/app/models/preview_card_provider_filter.rb b/app/models/preview_card_provider_filter.rb new file mode 100644 index 000000000..1e90d3c9d --- /dev/null +++ b/app/models/preview_card_provider_filter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class PreviewCardProviderFilter + KEYS = %i( + status + ).freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = PreviewCardProvider.unscoped + + params.each do |key, value| + next if key.to_s == 'page' + + scope.merge!(scope_for(key, value.to_s.strip)) if value.present? + end + + scope.order(domain: :asc) + end + + private + + def scope_for(key, value) + case key.to_s + when 'status' + status_scope(value) + else + raise "Unknown filter: #{key}" + end + end + + def status_scope(value) + case value.to_s + when 'approved' + PreviewCardProvider.trendable + when 'rejected' + PreviewCardProvider.not_trendable + when 'pending_review' + PreviewCardProvider.pending_review + else + raise "Unknown status: #{value}" + end + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb index dcce28391..f35d92b5d 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -36,6 +36,7 @@ class Tag < ApplicationRecord scope :usable, -> { where(usable: [true, nil]) } scope :listable, -> { where(listable: [true, nil]) } scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) } + scope :not_trendable, -> { where(trendable: false) } scope :recently_used, ->(account) { joins(:statuses).where(statuses: { id: account.statuses.select(:id).limit(1000) }).group(:id).order(Arel.sql('count(*) desc')) } scope :matches_name, ->(term) { where(arel_table[:name].lower.matches(arel_table.lower("#{sanitize_sql_like(Tag.normalize(term))}%"), nil, true)) } # Search with case-sensitive to use B-tree index @@ -75,28 +76,12 @@ class Tag < ApplicationRecord requested_review_at.present? end - def use!(account, status: nil, at_time: Time.now.utc) - TrendingTags.record_use!(self, account, status: status, at_time: at_time) - end - - def trending? - TrendingTags.trending?(self) + def requires_review_notification? + requires_review? && !requested_review? end def history - days = [] - - 7.times do |i| - day = i.days.ago.beginning_of_day.to_i - - days << { - day: day.to_s, - uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0', - accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s, - } - end - - days + @history ||= Trends::History.new('tags', id) end class << self diff --git a/app/models/tag_filter.rb b/app/models/tag_filter.rb index 85bfcbea5..ecdb52503 100644 --- a/app/models/tag_filter.rb +++ b/app/models/tag_filter.rb @@ -2,13 +2,8 @@ class TagFilter KEYS = %i( - directory - reviewed - unreviewed - pending_review - popular - active - name + trending + status ).freeze attr_reader :params @@ -18,7 +13,13 @@ class TagFilter end def results - scope = Tag.unscoped + scope = begin + if params[:status] == 'pending_review' + Tag.unscoped + else + trending_scope + end + end params.each do |key, value| next if key.to_s == 'page' @@ -26,27 +27,40 @@ class TagFilter scope.merge!(scope_for(key, value.to_s.strip)) if value.present? end - scope.order(id: :desc) + scope end private def scope_for(key, value) case key.to_s - when 'reviewed' - Tag.reviewed.order(reviewed_at: :desc) - when 'unreviewed' - Tag.unreviewed - when 'pending_review' - Tag.pending_review.order(requested_review_at: :desc) - when 'popular' - Tag.order('max_score DESC NULLS LAST') - when 'active' - Tag.order('last_status_at DESC NULLS LAST') - when 'name' - Tag.matches_name(value) + when 'status' + status_scope(value) else raise "Unknown filter: #{key}" end end + + def trending_scope + ids = Trends.tags.currently_trending_ids(false, -1) + + if ids.empty? + Tag.none + else + Tag.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id").order('x.ordering') + end + end + + def status_scope(value) + case value.to_s + when 'approved' + Tag.trendable + when 'rejected' + Tag.not_trendable + when 'pending_review' + Tag.pending_review + else + raise "Unknown status: #{value}" + end + end end diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb deleted file mode 100644 index 31890b082..000000000 --- a/app/models/trending_tags.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -class TrendingTags - KEY = 'trending_tags' - EXPIRE_HISTORY_AFTER = 7.days.seconds - EXPIRE_TRENDS_AFTER = 1.day.seconds - THRESHOLD = 5 - LIMIT = 10 - REVIEW_THRESHOLD = 3 - MAX_SCORE_COOLDOWN = 2.days.freeze - MAX_SCORE_HALFLIFE = 2.hours.freeze - - class << self - include Redisable - - def record_use!(tag, account, status: nil, at_time: Time.now.utc) - return unless tag.usable? && !account.silenced? - - # Even if a tag is not allowed to trend, we still need to - # record the stats since they can be displayed in other places - increment_historical_use!(tag.id, at_time) - increment_unique_use!(tag.id, account.id, at_time) - increment_use!(tag.id, at_time) - - # Only update when the tag was last used once every 12 hours - # and only if a status is given (lets use ignore reblogs) - tag.update(last_status_at: at_time) if status.present? && (tag.last_status_at.nil? || (tag.last_status_at < at_time && tag.last_status_at < 12.hours.ago)) - end - - def update!(at_time = Time.now.utc) - tag_ids = redis.smembers("#{KEY}:used:#{at_time.beginning_of_day.to_i}") + redis.zrange(KEY, 0, -1) - tags = Tag.trendable.where(id: tag_ids.uniq) - - # First pass to calculate scores and update the set - - tags.each do |tag| - expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f - expected = 1.0 if expected.zero? - observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f - max_time = tag.max_score_at - max_score = tag.max_score - max_score = 0 if max_time.nil? || max_time < (at_time - MAX_SCORE_COOLDOWN) - - score = begin - if expected > observed || observed < THRESHOLD - 0 - else - ((observed - expected)**2) / expected - end - end - - if score > max_score - max_score = score - max_time = at_time - - # Not interested in triggering any callbacks for this - tag.update_columns(max_score: max_score, max_score_at: max_time) - end - - decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / MAX_SCORE_HALFLIFE.to_f)) - - if decaying_score.zero? - redis.zrem(KEY, tag.id) - else - redis.zadd(KEY, decaying_score, tag.id) - end - end - - users_for_review = User.staff.includes(:account).to_a.select(&:allows_trending_tag_emails?) - - # Second pass to notify about previously unreviewed trends - - tags.each do |tag| - current_rank = redis.zrevrank(KEY, tag.id) - needs_review_notification = tag.requires_review? && !tag.requested_review? - rank_passes_threshold = current_rank.present? && current_rank <= REVIEW_THRESHOLD - - next unless !tag.trendable? && rank_passes_threshold && needs_review_notification - - tag.touch(:requested_review_at) - - users_for_review.each do |user| - AdminMailer.new_trending_tag(user.account, tag).deliver_later! - end - end - - # Trim older items - - redis.zremrangebyrank(KEY, 0, -(LIMIT + 1)) - redis.zremrangebyscore(KEY, '(0.3', '-inf') - end - - def get(limit, filtered: true) - tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i) - - tags = Tag.where(id: tag_ids) - tags = tags.trendable if filtered - tags = tags.index_by(&:id) - - tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit) - end - - def trending?(tag) - rank = redis.zrevrank(KEY, tag.id) - rank.present? && rank < LIMIT - end - - private - - def increment_historical_use!(tag_id, at_time) - key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}" - redis.incrby(key, 1) - redis.expire(key, EXPIRE_HISTORY_AFTER) - end - - def increment_unique_use!(tag_id, account_id, at_time) - key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts" - redis.pfadd(key, account_id) - redis.expire(key, EXPIRE_HISTORY_AFTER) - end - - def increment_use!(tag_id, at_time) - key = "#{KEY}:used:#{at_time.beginning_of_day.to_i}" - redis.sadd(key, tag_id) - redis.expire(key, EXPIRE_HISTORY_AFTER) - end - end -end diff --git a/app/models/trends.rb b/app/models/trends.rb new file mode 100644 index 000000000..7dd3a9c87 --- /dev/null +++ b/app/models/trends.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Trends + def self.table_name_prefix + 'trends_' + end + + def self.links + @links ||= Trends::Links.new + end + + def self.tags + @tags ||= Trends::Tags.new + end + + def self.refresh! + [links, tags].each(&:refresh) + end + + def self.request_review! + [links, tags].each(&:request_review) if enabled? + end + + def self.enabled? + Setting.trends + end +end diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb new file mode 100644 index 000000000..b767dcb1a --- /dev/null +++ b/app/models/trends/base.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +class Trends::Base + include Redisable + + class_attribute :default_options + + attr_reader :options + + # @param [Hash] options + # @option options [Integer] :threshold Minimum amount of uses by unique accounts to begin calculating the score + # @option options [Integer] :review_threshold Minimum rank (lower = better) before requesting a review + # @option options [ActiveSupport::Duration] :max_score_cooldown For this amount of time, the peak score (if bigger than current score) is decayed-from + # @option options [ActiveSupport::Duration] :max_score_halflife How quickly a peak score decays + def initialize(options = {}) + @options = self.class.default_options.merge(options) + end + + def register(_status) + raise NotImplementedError + end + + def add(*) + raise NotImplementedError + end + + def refresh(*) + raise NotImplementedError + end + + def request_review + raise NotImplementedError + end + + def get(*) + raise NotImplementedError + end + + def score(id) + redis.zscore("#{key_prefix}:all", id) || 0 + end + + def rank(id) + redis.zrevrank("#{key_prefix}:allowed", id) + end + + def currently_trending_ids(allowed, limit) + redis.zrevrange(allowed ? "#{key_prefix}:allowed" : "#{key_prefix}:all", 0, limit.positive? ? limit - 1 : limit).map(&:to_i) + end + + protected + + def key_prefix + raise NotImplementedError + end + + def recently_used_ids(at_time = Time.now.utc) + redis.smembers(used_key(at_time)).map(&:to_i) + end + + def record_used_id(id, at_time = Time.now.utc) + redis.sadd(used_key(at_time), id) + redis.expire(used_key(at_time), 1.day.seconds) + end + + def trim_older_items + redis.zremrangebyscore("#{key_prefix}:all", '-inf', '(1') + redis.zremrangebyscore("#{key_prefix}:allowed", '-inf', '(1') + end + + def score_at_rank(rank) + redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0 + end + + private + + def used_key(at_time) + "#{key_prefix}:used:#{at_time.beginning_of_day.to_i}" + end +end diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb new file mode 100644 index 000000000..608e33792 --- /dev/null +++ b/app/models/trends/history.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +class Trends::History + include Enumerable + + class Aggregate + include Redisable + + def initialize(prefix, id, date_range) + @days = date_range.map { |date| Day.new(prefix, id, date.to_time(:utc)) } + end + + def uses + redis.mget(*@days.map { |day| day.key_for(:uses) }).map(&:to_i).sum + end + + def accounts + redis.pfcount(*@days.map { |day| day.key_for(:accounts) }) + end + end + + class Day + include Redisable + + EXPIRE_AFTER = 14.days.seconds + + def initialize(prefix, id, day) + @prefix = prefix + @id = id + @day = day.beginning_of_day + end + + attr_reader :day + + def accounts + redis.pfcount(key_for(:accounts)) + end + + def uses + redis.get(key_for(:uses))&.to_i || 0 + end + + def add(account_id) + redis.pipelined do + redis.incrby(key_for(:uses), 1) + redis.pfadd(key_for(:accounts), account_id) + redis.expire(key_for(:uses), EXPIRE_AFTER) + redis.expire(key_for(:accounts), EXPIRE_AFTER) + end + end + + def as_json + { day: day.to_i.to_s, accounts: accounts.to_s, uses: uses.to_s } + end + + def key_for(suffix) + case suffix + when :accounts + "#{key_prefix}:#{suffix}" + when :uses + key_prefix + end + end + + def key_prefix + "activity:#{@prefix}:#{@id}:#{day.to_i}" + end + end + + def initialize(prefix, id) + @prefix = prefix + @id = id + end + + def get(date) + Day.new(@prefix, @id, date) + end + + def add(account_id, at_time = Time.now.utc) + Day.new(@prefix, @id, at_time).add(account_id) + end + + def aggregate(date_range) + Aggregate.new(@prefix, @id, date_range) + end + + def each(&block) + if block_given? + (0...7).map { |i| block.call(get(i.days.ago)) } + else + to_enum(:each) + end + end + + def as_json(*) + map(&:as_json) + end +end diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb new file mode 100644 index 000000000..a0d65138b --- /dev/null +++ b/app/models/trends/links.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class Trends::Links < Trends::Base + PREFIX = 'trending_links' + + self.default_options = { + threshold: 15, + review_threshold: 10, + max_score_cooldown: 2.days.freeze, + max_score_halflife: 8.hours.freeze, + } + + def register(status, at_time = Time.now.utc) + original_status = status.reblog? ? status.reblog : status + + return unless original_status.public_visibility? && status.public_visibility? && + !original_status.account.silenced? && !status.account.silenced? && + !original_status.spoiler_text? + + original_status.preview_cards.each do |preview_card| + add(preview_card, status.account_id, at_time) if preview_card.appropriate_for_trends? + end + end + + def add(preview_card, account_id, at_time = Time.now.utc) + preview_card.history.add(account_id, at_time) + record_used_id(preview_card.id, at_time) + end + + def get(allowed, limit) + preview_card_ids = currently_trending_ids(allowed, limit) + preview_cards = PreviewCard.where(id: preview_card_ids).index_by(&:id) + preview_card_ids.map { |id| preview_cards[id] }.compact + end + + def refresh(at_time = Time.now.utc) + preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq) + calculate_scores(preview_cards, at_time) + trim_older_items + end + + def request_review + preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1)) + + preview_cards_requiring_review = preview_cards.filter_map do |preview_card| + next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification? + + if preview_card.provider.nil? + preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc) + else + preview_card.provider.touch(:requested_review_at) + end + + preview_card + end + + return if preview_cards_requiring_review.empty? + + User.staff.includes(:account).find_each do |user| + AdminMailer.new_trending_links(user.account, preview_cards_requiring_review).deliver_later! if user.allows_trending_tag_emails? + end + end + + protected + + def key_prefix + PREFIX + end + + private + + def calculate_scores(preview_cards, at_time) + preview_cards.each do |preview_card| + expected = preview_card.history.get(at_time - 1.day).accounts.to_f + expected = 1.0 if expected.zero? + observed = preview_card.history.get(at_time).accounts.to_f + max_time = preview_card.max_score_at + max_score = preview_card.max_score + max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown]) + + score = begin + if expected > observed || observed < options[:threshold] + 0 + else + ((observed - expected)**2) / expected + end + end + + if score > max_score + max_score = score + max_time = at_time + + # Not interested in triggering any callbacks for this + preview_card.update_columns(max_score: max_score, max_score_at: max_time) + end + + decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f)) + + if decaying_score.zero? + redis.zrem("#{PREFIX}:all", preview_card.id) + redis.zrem("#{PREFIX}:allowed", preview_card.id) + else + redis.zadd("#{PREFIX}:all", decaying_score, preview_card.id) + + if preview_card.trendable? + redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id) + else + redis.zrem("#{PREFIX}:allowed", preview_card.id) + end + end + end + end + + def would_be_trending?(id) + score(id) > score_at_rank(options[:review_threshold] - 1) + end +end diff --git a/app/models/trends/tags.rb b/app/models/trends/tags.rb new file mode 100644 index 000000000..13e0ab56b --- /dev/null +++ b/app/models/trends/tags.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class Trends::Tags < Trends::Base + PREFIX = 'trending_tags' + + self.default_options = { + threshold: 15, + review_threshold: 10, + max_score_cooldown: 2.days.freeze, + max_score_halflife: 4.hours.freeze, + } + + def register(status, at_time = Time.now.utc) + original_status = status.reblog? ? status.reblog : status + + return unless original_status.public_visibility? && status.public_visibility? && + !original_status.account.silenced? && !status.account.silenced? + + original_status.tags.each do |tag| + add(tag, status.account_id, at_time) if tag.usable? + end + end + + def add(tag, account_id, at_time = Time.now.utc) + tag.history.add(account_id, at_time) + record_used_id(tag.id, at_time) + end + + def refresh(at_time = Time.now.utc) + tags = Tag.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq) + calculate_scores(tags, at_time) + trim_older_items + end + + def get(allowed, limit) + tag_ids = currently_trending_ids(allowed, limit) + tags = Tag.where(id: tag_ids).index_by(&:id) + tag_ids.map { |id| tags[id] }.compact + end + + def request_review + tags = Tag.where(id: currently_trending_ids(false, -1)) + + tags_requiring_review = tags.filter_map do |tag| + next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification? + + tag.touch(:requested_review_at) + tag + end + + return if tags_requiring_review.empty? + + User.staff.includes(:account).find_each do |user| + AdminMailer.new_trending_tags(user.account, tags_requiring_review).deliver_later! if user.allows_trending_tag_emails? + end + end + + protected + + def key_prefix + PREFIX + end + + private + + def calculate_scores(tags, at_time) + tags.each do |tag| + expected = tag.history.get(at_time - 1.day).accounts.to_f + expected = 1.0 if expected.zero? + observed = tag.history.get(at_time).accounts.to_f + max_time = tag.max_score_at + max_score = tag.max_score + max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown]) + + score = begin + if expected > observed || observed < options[:threshold] + 0 + else + ((observed - expected)**2) / expected + end + end + + if score > max_score + max_score = score + max_time = at_time + + # Not interested in triggering any callbacks for this + tag.update_columns(max_score: max_score, max_score_at: max_time) + end + + decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f)) + + if decaying_score.zero? + redis.zrem("#{PREFIX}:all", tag.id) + redis.zrem("#{PREFIX}:allowed", tag.id) + else + redis.zadd("#{PREFIX}:all", decaying_score, tag.id) + + if tag.trendable? + redis.zadd("#{PREFIX}:allowed", decaying_score, tag.id) + else + redis.zrem("#{PREFIX}:allowed", tag.id) + end + end + end + end + + def would_be_trending?(id) + score(id) > score_at_rank(options[:review_threshold] - 1) + end +end diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb new file mode 100644 index 000000000..4f485d7fc --- /dev/null +++ b/app/policies/preview_card_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class PreviewCardPolicy < ApplicationPolicy + def index? + staff? + end + + def update? + staff? + end +end diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb new file mode 100644 index 000000000..598d54a5e --- /dev/null +++ b/app/policies/preview_card_provider_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class PreviewCardProviderPolicy < ApplicationPolicy + def index? + staff? + end + + def update? + staff? + end +end diff --git a/app/serializers/rest/trends/link_serializer.rb b/app/serializers/rest/trends/link_serializer.rb new file mode 100644 index 000000000..232483490 --- /dev/null +++ b/app/serializers/rest/trends/link_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::Trends::LinkSerializer < REST::PreviewCardSerializer + attributes :history +end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 51956ce7e..94dc6389f 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -50,7 +50,7 @@ class FetchLinkCardService < BaseService # We follow redirects, and ideally we want to save the preview card for # the destination URL and not any link shortener in-between, so here # we set the URL to the one of the last response in the redirect chain - @url = res.request.uri.to_s.to_s + @url = res.request.uri.to_s @card = PreviewCard.find_or_initialize_by(url: @url) if @card.url != @url if res.code == 200 && res.mime_type == 'text/html' @@ -66,6 +66,7 @@ class FetchLinkCardService < BaseService def attach_card @status.preview_cards << @card Rails.cache.delete(@status) + Trends.links.register(@status) end def parse_urls diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 85aaec4d6..294ae43eb 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -91,7 +91,8 @@ class PostStatusService < BaseService end def postprocess_status! - LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text? + Trends.tags.register(@status) + LinkCrawlWorker.perform_async(@status.id) DistributionWorker.perform_async(@status.id) ActivityPub::DistributionWorker.perform_async(@status.id) PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index c42b79db8..47277c56c 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -8,7 +8,7 @@ class ProcessHashtagsService < BaseService Tag.find_or_create_by_names(tags) do |tag| status.tags << tag records << tag - tag.use!(status.account, status: status, at_time: status.created_at) if status.public_visibility? + tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago) end return unless status.distributable? diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 744bdf567..ece91847a 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -30,12 +30,13 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit]) + Trends.tags.register(reblog) + Trends.links.register(reblog) DistributionWorker.perform_async(reblog.id) ActivityPub::DistributionWorker.perform_async(reblog.id) create_notification(reblog) bump_potential_friendship(account, reblog) - record_use(account, reblog) reblog end @@ -60,16 +61,6 @@ class ReblogService < BaseService PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog) end - def record_use(account, reblog) - return unless reblog.public_visibility? - - original_status = reblog.reblog - - original_status.tags.each do |tag| - tag.use!(account) - end - end - def build_json(reblog) Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account)) end diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 560eba7b4..895333a58 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -42,7 +42,7 @@ %span= t('admin.dashboard.pending_users_html', count: @pending_users_count) = fa_icon 'chevron-right fw' - = link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do + = link_to admin_trends_tags_path(status: 'pending_review'), class: 'dashboard__quick-access' do %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count) = fa_icon 'chevron-right fw' diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml deleted file mode 100644 index ac0c72816..000000000 --- a/app/views/admin/tags/_tag.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -.batch-table__row - - if batch_available - %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox - = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id - - .directory__tag - = link_to admin_tag_path(tag.id) do - %h4 - = fa_icon 'hashtag' - = tag.name - - %small - = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts]) - - - if tag.trending? - = fa_icon 'fire fw' - = t('admin.tags.trending_right_now') - - .trends__item__current= friendly_number_to_human tag.history.first[:uses] diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml deleted file mode 100644 index d78f3c6d1..000000000 --- a/app/views/admin/tags/index.html.haml +++ /dev/null @@ -1,74 +0,0 @@ -- content_for :page_title do - = t('admin.tags.title') - -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - -.filters - .filter-subset - %strong= t('admin.tags.review') - %ul - %li= filter_link_to t('generic.all'), reviewed: nil, unreviewed: nil, pending_review: nil - %li= filter_link_to t('admin.tags.unreviewed'), unreviewed: '1', reviewed: nil, pending_review: nil - %li= filter_link_to t('admin.tags.reviewed'), reviewed: '1', unreviewed: nil, pending_review: nil - %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), pending_review: '1', reviewed: nil, unreviewed: nil - - .filter-subset - %strong= t('generic.order_by') - %ul - %li= filter_link_to t('admin.tags.most_recent'), popular: nil, active: nil - %li= filter_link_to t('admin.tags.last_active'), active: '1', popular: nil - %li= filter_link_to t('admin.tags.most_popular'), popular: '1', active: nil - - -= form_tag admin_tags_url, method: 'GET', class: 'simple_form' do - .fields-group - - TagFilter::KEYS.each do |key| - = hidden_field_tag key, params[key] if params[key].present? - - - %i(name).each do |key| - .input.string.optional - = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.tags.#{key}") - - .actions - %button.button= t('admin.accounts.search') - = link_to t('admin.accounts.reset'), admin_tags_path, class: 'button negative' - -%hr.spacer/ - -= form_for(@form, url: batch_admin_tags_path) do |f| - = hidden_field_tag :page, params[:page] || 1 - - - TagFilter::KEYS.each do |key| - = hidden_field_tag key, params[key] if params[key].present? - - .batch-table.optional - .batch-table__toolbar - - if params[:pending_review] == '1' || params[:unreviewed] == '1' - %label.batch-table__toolbar__select.batch-checkbox-all - = check_box_tag :batch_checkbox_all, nil, false - .batch-table__toolbar__actions - = f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - - = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - - else - .batch-table__toolbar__actions - %span.neutral-hint= t('generic.no_batch_actions_available') - - .batch-table__body - - if @tags.empty? - = nothing_here 'nothing-here--under-tabs' - - else - = render partial: 'tag', collection: @tags, locals: { f: f, batch_available: params[:pending_review] == '1' || params[:unreviewed] == '1' } - -= paginate @tags - -- if params[:pending_review] == '1' || params[:unreviewed] == '1' - %hr.spacer/ - - %div.action-buttons - %div - = link_to t('admin.accounts.approve_all'), approve_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' - - %div - = link_to t('admin.accounts.reject_all'), reject_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml index c4caffda1..007dc005e 100644 --- a/app/views/admin/tags/show.html.haml +++ b/app/views/admin/tags/show.html.haml @@ -1,15 +1,50 @@ +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + - content_for :page_title do = "##{@tag.name}" -.dashboard__counters - %div - = link_to tag_url(@tag), target: '_blank', rel: 'noopener noreferrer' do - .dashboard__counters__num= number_with_delimiter @accounts_today - .dashboard__counters__label= t 'admin.tags.accounts_today' - %div - %div - .dashboard__counters__num= number_with_delimiter @accounts_week - .dashboard__counters__label= t 'admin.tags.accounts_week' +- content_for :heading_actions do + = l(@time_period.first) + = ' - ' + = l(@time_period.last) + +.dashboard + .dashboard__item + = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure') + .dashboard__item + = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure') + .dashboard__item + = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure') + .dashboard__item + = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension') + .dashboard__item + = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension') + .dashboard__item + = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do + - if @tag.usable? + %span= t('admin.trends.tags.usable') + = fa_icon 'check fw' + - else + %span= t('admin.trends.tags.not_usable') + = fa_icon 'lock fw' + + = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do + - if @tag.trendable? + %span= t('admin.trends.tags.trendable') + = fa_icon 'check fw' + - else + %span= t('admin.trends.tags.not_trendable') + = fa_icon 'lock fw' + + + = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do + - if @tag.listable? + %span= t('admin.trends.tags.listable') + = fa_icon 'check fw' + - else + %span= t('admin.trends.tags.not_listable') + = fa_icon 'lock fw' %hr.spacer/ @@ -26,18 +61,3 @@ .actions = f.button :button, t('generic.save_changes'), type: :submit - -%hr.spacer/ - -%h3= t 'admin.tags.breakdown' - -.table-wrapper - %table.table - %tbody - - total = @usage_by_domain.sum(&:last).to_f - - - @usage_by_domain.each do |(domain, count)| - %tr - %th= domain || site_hostname - %td= number_to_percentage((count / total) * 100, precision: 1) - %td= number_with_delimiter count diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml new file mode 100644 index 000000000..dfed13b68 --- /dev/null +++ b/app/views/admin/trends/links/_preview_card.html.haml @@ -0,0 +1,30 @@ +.batch-table__row{ class: [preview_card.provider&.requires_review? && 'batch-table__row--attention', !preview_card.provider&.requires_review? && !preview_card.trendable? && 'batch-table__row--muted'] } + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :preview_card_ids, { multiple: true, include_hidden: false }, preview_card.id + + .batch-table__row__content.pending-account + .pending-account__header + = link_to preview_card.title, preview_card.url + + %br/ + + - if preview_card.provider_name.present? + = preview_card.provider_name + • + + - if preview_card.language.present? + = human_locale(preview_card.language) + • + + = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts }) + + - if preview_card.trendable? && (rank = Trends.links.rank(preview_card.id)) + • + %abbr{ title: t('admin.trends.tags.current_score', score: Trends.links.score(preview_card.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) + + - if preview_card.max_score_at && preview_card.max_score_at >= Trends::Links::MAX_SCORE_COOLDOWN.ago && preview_card.max_score_at < 1.day.ago + • + = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short)) + - elsif preview_card.provider&.requires_review? + • + = t('admin.trends.pending_review') diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml new file mode 100644 index 000000000..240ae722b --- /dev/null +++ b/app/views/admin/trends/links/index.html.haml @@ -0,0 +1,41 @@ +- content_for :page_title do + = t('admin.trends.links.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + +.filters + .filter-subset + %strong= t('admin.trends.trending') + %ul + %li= filter_link_to t('generic.all'), trending: nil + %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed' + .back-link + = link_to admin_trends_links_preview_card_providers_path do + = t('admin.trends.preview_card_providers.title') + = fa_icon 'chevron-right fw' + +%hr.spacer/ + += form_for(@form, url: batch_admin_trends_links_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - PreviewCardFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + .batch-table__body + - if @preview_cards.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'preview_card', collection: @preview_cards, locals: { f: f } + += paginate @preview_cards diff --git a/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml b/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml new file mode 100644 index 000000000..e40e6529d --- /dev/null +++ b/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml @@ -0,0 +1,16 @@ +.batch-table__row{ class: [preview_card_provider.requires_review? && 'batch-table__row--attention', !preview_card_provider.requires_review? && !preview_card_provider.trendable? && 'batch-table__row--muted'] } + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :preview_card_provider_ids, { multiple: true, include_hidden: false }, preview_card_provider.id + + .batch-table__row__content.pending-account + .pending-account__header + %strong= preview_card_provider.domain + + %br/ + + - if preview_card_provider.requires_review? + = t('admin.trends.pending_review') + - elsif preview_card_provider.trendable? + = t('admin.trends.preview_card_providers.allowed') + - else + = t('admin.trends.preview_card_providers.rejected') diff --git a/app/views/admin/trends/links/preview_card_providers/index.html.haml b/app/views/admin/trends/links/preview_card_providers/index.html.haml new file mode 100644 index 000000000..eac6e641f --- /dev/null +++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml @@ -0,0 +1,43 @@ +- content_for :page_title do + = t('admin.trends.preview_card_providers.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + +.filters + .filter-subset + %strong= t('admin.tags.review') + %ul + %li= filter_link_to t('generic.all'), status: nil + %li= filter_link_to t('admin.trends.approved'), status: 'approved' + %li= filter_link_to t('admin.trends.rejected'), status: 'rejected' + %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{PreviewCardProvider.pending_review.count})"], ' '), status: 'pending_review' + .back-link + = link_to admin_trends_links_path do + = fa_icon 'chevron-left fw' + = t('admin.trends.links.title') + + +%hr.spacer/ + += form_for(@form, url: batch_admin_trends_links_preview_card_providers_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - PreviewCardProviderFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .batch-table.optional + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + = f.button safe_join([fa_icon('check'), t('admin.trends.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + .batch-table__body + - if @preview_card_providers.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'preview_card_provider', collection: @preview_card_providers, locals: { f: f } + += paginate @preview_card_providers diff --git a/app/views/admin/trends/tags/_tag.html.haml b/app/views/admin/trends/tags/_tag.html.haml new file mode 100644 index 000000000..c4af77b00 --- /dev/null +++ b/app/views/admin/trends/tags/_tag.html.haml @@ -0,0 +1,24 @@ +.batch-table__row{ class: [tag.requires_review? && 'batch-table__row--attention', !tag.requires_review? && !tag.trendable? && 'batch-table__row--muted'] } + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id + + .batch-table__row__content.pending-account + .pending-account__header + = link_to admin_tag_path(tag.id) do + = fa_icon 'hashtag' + = tag.name + + %br/ + + = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts }) + + - if tag.trendable? && (rank = Trends.tags.rank(tag.id)) + • + %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) + + - if tag.max_score_at && tag.max_score_at >= Trends::Tags::MAX_SCORE_COOLDOWN.ago && tag.max_score_at < 1.day.ago + • + = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short)) + - elsif tag.requires_review? + • + = t('admin.trends.pending_review') diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml new file mode 100644 index 000000000..8df0a9920 --- /dev/null +++ b/app/views/admin/trends/tags/index.html.haml @@ -0,0 +1,38 @@ +- content_for :page_title do + = t('admin.trends.tags.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + +.filters + .filter-subset + %strong= t('admin.tags.review') + %ul + %li= filter_link_to t('generic.all'), status: nil + %li= filter_link_to t('admin.trends.approved'), status: 'approved' + %li= filter_link_to t('admin.trends.rejected'), status: 'rejected' + %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), status: 'pending_review' + +%hr.spacer/ + += form_for(@form, url: batch_admin_trends_tags_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - TagFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .batch-table.optional + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + = f.button safe_join([fa_icon('check'), t('admin.trends.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + .batch-table__body + - if @tags.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'tag', collection: @tags, locals: { f: f } + += paginate @tags diff --git a/app/views/admin_mailer/new_trending_links.text.erb b/app/views/admin_mailer/new_trending_links.text.erb new file mode 100644 index 000000000..51789aca5 --- /dev/null +++ b/app/views/admin_mailer/new_trending_links.text.erb @@ -0,0 +1,16 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_trending_links.body') %> + +<% @links.each do |link| %> +- <%= link.title %> • <%= link.url %> + <%= t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %> +<% end %> + +<% if @lowest_trending_link %> +<%= t('admin_mailer.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2)) %> +<% else %> +<%= t('admin_mailer.new_trending_links.no_approved_links') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %> diff --git a/app/views/admin_mailer/new_trending_tag.text.erb b/app/views/admin_mailer/new_trending_tag.text.erb deleted file mode 100644 index e4bfdc591..000000000 --- a/app/views/admin_mailer/new_trending_tag.text.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%= raw t('application_mailer.salutation', name: display_name(@me)) %> - -<%= raw t('admin_mailer.new_trending_tag.body', name: @tag.name) %> - -<%= raw t('application_mailer.view')%> <%= admin_tags_url(pending_review: '1') %> diff --git a/app/views/admin_mailer/new_trending_tags.text.erb b/app/views/admin_mailer/new_trending_tags.text.erb new file mode 100644 index 000000000..5051e8a96 --- /dev/null +++ b/app/views/admin_mailer/new_trending_tags.text.erb @@ -0,0 +1,16 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_trending_tags.body') %> + +<% @tags.each do |tag| %> +- #<%= tag.name %> + <%= t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %> +<% end %> + +<% if @lowest_trending_tag %> +<%= t('admin_mailer.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2)) %> +<% else %> +<%= t('admin_mailer.new_trending_tags.no_approved_tags') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(pending_review: '1') %> diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml index 7ec91c06a..6826c3b58 100644 --- a/app/views/application/_sidebar.html.haml +++ b/app/views/application/_sidebar.html.haml @@ -6,7 +6,7 @@ %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') - if Setting.trends && !(user_signed_in? && !current_user.setting_trends) - - trends = TrendingTags.get(3) + - trends = Trends.tags.get(true, 3) - unless trends.empty? .endorsements-widget.trends-widget diff --git a/app/workers/scheduler/trending_tags_scheduler.rb b/app/workers/scheduler/trending_tags_scheduler.rb deleted file mode 100644 index 94d76d010..000000000 --- a/app/workers/scheduler/trending_tags_scheduler.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class Scheduler::TrendingTagsScheduler - include Sidekiq::Worker - - sidekiq_options retry: 0 - - def perform - TrendingTags.update! if Setting.trends - end -end diff --git a/app/workers/scheduler/trends/refresh_scheduler.rb b/app/workers/scheduler/trends/refresh_scheduler.rb new file mode 100644 index 000000000..b559ba46b --- /dev/null +++ b/app/workers/scheduler/trends/refresh_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Scheduler::Trends::RefreshScheduler + include Sidekiq::Worker + + sidekiq_options retry: 0 + + def perform + Trends.refresh! + end +end diff --git a/app/workers/scheduler/trends/review_notifications_scheduler.rb b/app/workers/scheduler/trends/review_notifications_scheduler.rb new file mode 100644 index 000000000..f334261bd --- /dev/null +++ b/app/workers/scheduler/trends/review_notifications_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Scheduler::Trends::ReviewNotificationsScheduler + include Sidekiq::Worker + + sidekiq_options retry: 0 + + def perform + Trends.request_review! + end +end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 35f2c3178..c032e5412 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -67,7 +67,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/account.rb", - "line": 479, + "line": 484, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "find_by_sql([\" WITH first_degree AS (\\n SELECT target_account_id\\n FROM follows\\n WHERE account_id = ?\\n UNION ALL\\n SELECT ?\\n )\\n SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n WHERE accounts.id IN (SELECT * FROM first_degree)\\n AND #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])", "render_path": null, @@ -100,6 +100,26 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "75fcd147b7611763ab6915faf8c5b0709e612b460f27c05c72d8b9bd0a6a77f8", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "lib/mastodon/snowflake.rb", + "line": 87, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "connection.execute(\"CREATE OR REPLACE FUNCTION timestamp_id(table_name text)\\nRETURNS bigint AS\\n$$\\n DECLARE\\n time_part bigint;\\n sequence_base bigint;\\n tail bigint;\\n BEGIN\\n time_part := (\\n -- Get the time in milliseconds\\n ((date_part('epoch', now()) * 1000))::bigint\\n -- And shift it over two bytes\\n << 16);\\n\\n sequence_base := (\\n 'x' ||\\n -- Take the first two bytes (four hex characters)\\n substr(\\n -- Of the MD5 hash of the data we documented\\n md5(table_name || '#{SecureRandom.hex(16)}' || time_part::text),\\n 1, 4\\n )\\n -- And turn it into a bigint\\n )::bit(16)::bigint;\\n\\n -- Finally, add our sequence number to our base, and chop\\n -- it to the last two bytes\\n tail := (\\n (sequence_base + nextval(table_name || '_id_seq'))\\n & 65535);\\n\\n -- Return the time part and the sequence part. OR appears\\n -- faster here than addition, but they're equivalent:\\n -- time_part has no trailing two bytes, and tail is only\\n -- the last two bytes.\\n RETURN time_part | tail;\\n END\\n$$ LANGUAGE plpgsql VOLATILE;\\n\")", + "render_path": null, + "location": { + "type": "method", + "class": "Mastodon::Snowflake", + "method": "define_timestamp_id" + }, + "user_input": "SecureRandom.hex(16)", + "confidence": "Medium", + "note": "" + }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -143,40 +163,40 @@ { "warning_type": "SQL Injection", "warning_code": 0, - "fingerprint": "9251d682c4e2840e1b2fea91e7d758efe2097ecb7f6255c065e3750d25eb178c", + "fingerprint": "8c1d8c4b76c1cd3960e90dff999f854a6ff742fcfd8de6c7184ac5a1b1a4d7dd", "check_name": "SQL", "message": "Possible SQL injection", - "file": "app/models/account.rb", - "line": 448, + "file": "app/models/preview_card_filter.rb", + "line": 50, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])", + "code": "PreviewCard.joins(\"join unnest(array[#{(Trends.links.currently_trending_ids(true, -1) or Trends.links.currently_trending_ids(false, -1)).map(&:to_i).join(\",\")}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id\")", "render_path": null, "location": { "type": "method", - "class": "Account", - "method": "search_for" + "class": "PreviewCardFilter", + "method": "trending_scope" }, - "user_input": "textsearch", + "user_input": "(Trends.links.currently_trending_ids(true, -1) or Trends.links.currently_trending_ids(false, -1)).map(&:to_i).join(\",\")", "confidence": "Medium", "note": "" }, { "warning_type": "SQL Injection", "warning_code": 0, - "fingerprint": "9ccb9ba6a6947400e187d515e0bf719d22993d37cfc123c824d7fafa6caa9ac3", + "fingerprint": "9251d682c4e2840e1b2fea91e7d758efe2097ecb7f6255c065e3750d25eb178c", "check_name": "SQL", "message": "Possible SQL injection", - "file": "lib/mastodon/snowflake.rb", - "line": 87, + "file": "app/models/account.rb", + "line": 453, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "connection.execute(\" CREATE OR REPLACE FUNCTION timestamp_id(table_name text)\\n RETURNS bigint AS\\n $$\\n DECLARE\\n time_part bigint;\\n sequence_base bigint;\\n tail bigint;\\n BEGIN\\n time_part := (\\n -- Get the time in milliseconds\\n ((date_part('epoch', now()) * 1000))::bigint\\n -- And shift it over two bytes\\n << 16);\\n\\n sequence_base := (\\n 'x' ||\\n -- Take the first two bytes (four hex characters)\\n substr(\\n -- Of the MD5 hash of the data we documented\\n md5(table_name ||\\n '#{SecureRandom.hex(16)}' ||\\n time_part::text\\n ),\\n 1, 4\\n )\\n -- And turn it into a bigint\\n )::bit(16)::bigint;\\n\\n -- Finally, add our sequence number to our base, and chop\\n -- it to the last two bytes\\n tail := (\\n (sequence_base + nextval(table_name || '_id_seq'))\\n & 65535);\\n\\n -- Return the time part and the sequence part. OR appears\\n -- faster here than addition, but they're equivalent:\\n -- time_part has no trailing two bytes, and tail is only\\n -- the last two bytes.\\n RETURN time_part | tail;\\n END\\n $$ LANGUAGE plpgsql VOLATILE;\\n\")", + "code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])", "render_path": null, "location": { "type": "method", - "class": "Mastodon::Snowflake", - "method": "define_timestamp_id" + "class": "Account", + "method": "search_for" }, - "user_input": "SecureRandom.hex(16)", + "user_input": "textsearch", "confidence": "Medium", "note": "" }, @@ -201,23 +221,53 @@ "note": "" }, { - "warning_type": "Redirect", - "warning_code": 18, - "fingerprint": "ba699ddcc6552c422c4ecd50d2cd217f616a2446659e185a50b05a0f2dad8d33", - "check_name": "Redirect", - "message": "Possible unprotected redirect", - "file": "app/controllers/media_controller.rb", - "line": 20, - "link": "https://brakemanscanner.org/docs/warning_types/redirect/", - "code": "redirect_to(MediaAttachment.attached.find_by!(:shortcode => ((params[:id] or params[:medium_id]))).file.url(:original))", + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "c32a484ccd9da46abd3bc93d08b72029d7dbc0576ccf4e878a9627e9a83cad2e", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/tag_filter.rb", + "line": 50, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Tag.joins(\"join unnest(array[#{Trends.tags.currently_trending_ids(false, -1).map(&:to_i).join(\",\")}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id\")", "render_path": null, "location": { "type": "method", - "class": "MediaController", - "method": "show" + "class": "TagFilter", + "method": "trending_scope" }, - "user_input": "MediaAttachment.attached.find_by!(:shortcode => ((params[:id] or params[:medium_id]))).file.url(:original)", - "confidence": "High", + "user_input": "Trends.tags.currently_trending_ids(false, -1).map(&:to_i).join(\",\")", + "confidence": "Medium", + "note": "" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "cd5cfd7f40037fbfa753e494d7129df16e358bfc43ef0da3febafbf4ee1ed3ac", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in `link_to` href", + "file": "app/views/admin/trends/links/_preview_card.html.haml", + "line": 7, + "link": "https://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to((Unresolved Model).new.title, (Unresolved Model).new.url)", + "render_path": [ + { + "type": "template", + "name": "admin/trends/links/index", + "line": 37, + "file": "app/views/admin/trends/links/index.html.haml", + "rendered": { + "name": "admin/trends/links/_preview_card", + "file": "app/views/admin/trends/links/_preview_card.html.haml" + } + } + ], + "location": { + "type": "template", + "template": "admin/trends/links/_preview_card" + }, + "user_input": "(Unresolved Model).new.url", + "confidence": "Weak", "note": "" }, { @@ -227,7 +277,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/account.rb", - "line": 495, + "line": 500, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "find_by_sql([\" SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])", "render_path": null, @@ -261,6 +311,6 @@ "note": "" } ], - "updated": "2021-05-11 20:22:27 +0900", - "brakeman_version": "5.0.1" + "updated": "2021-11-14 05:26:09 +0100", + "brakeman_version": "5.1.2" } diff --git a/config/locales/en.yml b/config/locales/en.yml index be15ad4b0..c98b82801 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -674,8 +674,8 @@ en: desc_html: Affects hashtags that have not been previously disallowed title: Allow hashtags to trend without prior review trends: - desc_html: Publicly display previously reviewed hashtags that are currently trending - title: Trending hashtags + desc_html: Publicly display previously reviewed content that is currently trending + title: Trends site_uploads: delete: Delete uploaded file destroyed_msg: Site upload successfully deleted! @@ -702,21 +702,51 @@ en: sidekiq_process_check: message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration tags: - accounts_today: Unique uses today - accounts_week: Unique uses this week - breakdown: Breakdown of today's usage by source - last_active: Recently used - most_popular: Most popular - most_recent: Recently created - name: Hashtag review: Review status - reviewed: Reviewed - title: Hashtags - trending_right_now: Trending right now - unique_uses_today: "%{count} posting today" - unreviewed: Not reviewed updated_msg: Hashtag settings updated successfully title: Administration + trends: + allow: Allow + approved: Approved + disallow: Disallow + links: + allow: Allow link + allow_provider: Allow publisher + disallow: Disallow link + disallow_provider: Disallow publisher + shared_by_over_week: + one: Shared by one person over the last week + other: Shared by %{count} people over the last week + title: Trending links + usage_comparison: Shared %{today} times today, compared to %{yesterday} yesterday + pending_review: Pending review + preview_card_providers: + allowed: Links from this publisher can trend + rejected: Links from this publisher won't trend + title: Publishers + rejected: Rejected + tags: + current_score: Current score %{score} + dashboard: + tag_accounts_measure: unique uses + tag_languages_dimension: Top languages + tag_servers_dimension: Top servers + tag_servers_measure: different servers + tag_uses_measure: total uses + listable: Can be suggested + not_listable: Won't be suggested + not_trendable: Won't appear under trends + not_usable: Cannot be used + peaked_on_and_decaying: Peaked on %{date}, now decaying + title: Trending hashtags + trendable: Can appear under trends + trending_rank: 'Trending #%{rank}' + usable: Can be used + usage_comparison: Used %{today} times today, compared to %{yesterday} yesterday + used_by_over_week: + one: Used by one person over the last week + other: Used by %{count} people over the last week + title: Trends warning_presets: add_new: Add new delete: Delete @@ -731,9 +761,16 @@ en: body: "%{reporter} has reported %{target}" body_remote: Someone from %{domain} has reported %{target} subject: New report for %{instance} (#%{id}) - new_trending_tag: - body: 'The hashtag #%{name} is trending today, but has not been previously reviewed. It will not be displayed publicly unless you allow it to, or just save the form as it is to never hear about it again.' - subject: New hashtag up for review on %{instance} (#%{name}) + new_trending_links: + body: The following links are trending today, but their publishers have not been previously reviewed. They will not be displayed publicly unless you approve them. Further notifications from the same publishers will not be generated. + no_approved_links: There are currently no approved trending links. + requirements: The lowest approved trending link is currently "%{lowest_link_title}" with a score of %{lowest_link_score}. + subject: New trending links up for review on %{instance} + new_trending_tags: + body: 'The following hashtags are trending today, but they have not been previously reviewed. They will not be displayed publicly unless you approve them:' + no_approved_tags: There are currently no approved trending hashtags. + requirements: 'The lowest approved trending hashtag is currently #%{lowest_tag_name} with a score of %{lowest_tag_score}.' + subject: New trending hashtags up for review on %{instance} aliases: add_new: Create alias created_msg: Successfully created a new alias. You can now initiate the move from the old account. @@ -940,7 +977,7 @@ en: changes_saved_msg: Changes successfully saved! copy: Copy delete: Delete - no_batch_actions_available: No batch actions available on this page + none: None order_by: Order by save_changes: Save changes validation_errors: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index bf864748c..d6376782d 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -204,8 +204,8 @@ en: mention: Someone mentioned you pending_account: New account needs review reblog: Someone boosted your post - report: New report is submitted - trending_tag: An unreviewed hashtag is trending + report: A new report is submitted + trending_tag: A new trend requires approval rule: text: Rule tag: diff --git a/config/navigation.rb b/config/navigation.rb index 37bfd7549..477d1c9ff 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -34,12 +34,16 @@ SimpleNavigation::Configuration.run do |navigation| n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' && current_user.functional? } n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? } + n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_tags_path, if: proc { current_user.staff? } do |s| + s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/tags|/admin/trends/tags} + s.item :links, safe_join([fa_icon('newspaper-o fw'), t('admin.trends.links.title')]), admin_trends_links_path, highlights_on: %r{/admin/trends/links} + end + n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s| s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts} s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path - s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags} s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations} s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } diff --git a/config/routes.rb b/config/routes.rb index 86f699516..c7317d173 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -301,12 +301,27 @@ Rails.application.routes.draw do resources :account_moderation_notes, only: [:create, :destroy] resource :follow_recommendations, only: [:show, :update] + resources :tags, only: [:show, :update] - resources :tags, only: [:index, :show, :update] do - collection do - post :approve_all - post :reject_all - post :batch + namespace :trends do + resources :links, only: [:index] do + collection do + post :batch + end + end + + resources :tags, only: [:index] do + collection do + post :batch + end + end + + namespace :links do + resources :preview_card_providers, only: [:index], path: :publishers do + collection do + post :batch + end + end end end end @@ -399,7 +414,7 @@ Rails.application.routes.draw do resources :favourites, only: [:index] resources :bookmarks, only: [:index] resources :reports, only: [:create] - resources :trends, only: [:index] + resources :trends, only: [:index], controller: 'trends/tags' resources :filters, only: [:index, :create, :show, :update, :destroy] resources :endorsements, only: [:index] resources :markers, only: [:index, :create] @@ -410,6 +425,11 @@ Rails.application.routes.draw do resources :apps, only: [:create] + namespace :trends do + resources :links, only: [:index] + resources :tags, only: [:index] + end + namespace :emails do resources :confirmations, only: [:create] end @@ -512,7 +532,9 @@ Rails.application.routes.draw do end end - resources :trends, only: [:index] + namespace :trends do + resources :tags, only: [:index] + end post :measures, to: 'measures#create' post :dimensions, to: 'dimensions#create' diff --git a/config/sidekiq.yml b/config/sidekiq.yml index eab74338e..9dde5a053 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -13,9 +13,13 @@ every: '5m' class: Scheduler::ScheduledStatusesScheduler queue: scheduler - trending_tags_scheduler: + trends_refresh_scheduler: every: '5m' - class: Scheduler::TrendingTagsScheduler + class: Scheduler::Trends::RefreshScheduler + queue: scheduler + trends_review_notifications_scheduler: + every: '2h' + class: Scheduler::Trends::ReviewNotificationsScheduler queue: scheduler media_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' diff --git a/db/migrate/20211031031021_create_preview_card_providers.rb b/db/migrate/20211031031021_create_preview_card_providers.rb new file mode 100644 index 000000000..0bd46198e --- /dev/null +++ b/db/migrate/20211031031021_create_preview_card_providers.rb @@ -0,0 +1,12 @@ +class CreatePreviewCardProviders < ActiveRecord::Migration[6.1] + def change + create_table :preview_card_providers do |t| + t.string :domain, null: false, default: '', index: { unique: true } + t.attachment :icon + t.boolean :trendable + t.datetime :reviewed_at + t.datetime :requested_review_at + t.timestamps + end + end +end diff --git a/db/migrate/20211112011713_add_language_to_preview_cards.rb b/db/migrate/20211112011713_add_language_to_preview_cards.rb new file mode 100644 index 000000000..995934de4 --- /dev/null +++ b/db/migrate/20211112011713_add_language_to_preview_cards.rb @@ -0,0 +1,7 @@ +class AddLanguageToPreviewCards < ActiveRecord::Migration[6.1] + def change + add_column :preview_cards, :language, :string + add_column :preview_cards, :max_score, :float + add_column :preview_cards, :max_score_at, :datetime + end +end diff --git a/db/migrate/20211115032527_add_trendable_to_preview_cards.rb b/db/migrate/20211115032527_add_trendable_to_preview_cards.rb new file mode 100644 index 000000000..87bf3d7a2 --- /dev/null +++ b/db/migrate/20211115032527_add_trendable_to_preview_cards.rb @@ -0,0 +1,5 @@ +class AddTrendableToPreviewCards < ActiveRecord::Migration[6.1] + def change + add_column :preview_cards, :trendable, :boolean + end +end diff --git a/db/migrate/20211123212714_add_link_type_to_preview_cards.rb b/db/migrate/20211123212714_add_link_type_to_preview_cards.rb new file mode 100644 index 000000000..9f57e0219 --- /dev/null +++ b/db/migrate/20211123212714_add_link_type_to_preview_cards.rb @@ -0,0 +1,5 @@ +class AddLinkTypeToPreviewCards < ActiveRecord::Migration[6.1] + def change + add_column :preview_cards, :link_type, :int + end +end diff --git a/db/schema.rb b/db/schema.rb index 2376afff7..00969daf1 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_08_08_071221) do +ActiveRecord::Schema.define(version: 2021_11_23_212714) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -689,6 +689,20 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do t.index ["status_id"], name: "index_polls_on_status_id" end + create_table "preview_card_providers", force: :cascade do |t| + t.string "domain", default: "", null: false + t.string "icon_file_name" + t.string "icon_content_type" + t.bigint "icon_file_size" + t.datetime "icon_updated_at" + t.boolean "trendable" + t.datetime "reviewed_at" + t.datetime "requested_review_at" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["domain"], name: "index_preview_card_providers_on_domain", unique: true + end + create_table "preview_cards", force: :cascade do |t| t.string "url", default: "", null: false t.string "title", default: "", null: false @@ -710,6 +724,11 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do t.string "embed_url", default: "", null: false t.integer "image_storage_schema_version" t.string "blurhash" + t.string "language" + t.float "max_score" + t.datetime "max_score_at" + t.boolean "trendable" + t.integer "link_type" t.index ["url"], name: "index_preview_cards_on_url", unique: true end diff --git a/lib/mastodon/snowflake.rb b/lib/mastodon/snowflake.rb index 8e2d82a97..fe0dc1722 100644 --- a/lib/mastodon/snowflake.rb +++ b/lib/mastodon/snowflake.rb @@ -84,10 +84,7 @@ module Mastodon::Snowflake -- Take the first two bytes (four hex characters) substr( -- Of the MD5 hash of the data we documented - md5(table_name || - '#{SecureRandom.hex(16)}' || - time_part::text - ), + md5(table_name || '#{SecureRandom.hex(16)}' || time_part::text), 1, 4 ) -- And turn it into a bigint diff --git a/lib/tasks/repo.rake b/lib/tasks/repo.rake index d004c5751..bbf7f20ee 100644 --- a/lib/tasks/repo.rake +++ b/lib/tasks/repo.rake @@ -96,7 +96,7 @@ namespace :repo do end.uniq.compact missing_available_locales = locales_in_files - I18n.available_locales - missing_locale_names = I18n.available_locales.reject { |locale| SettingsHelper::HUMAN_LOCALES.key?(locale) } + missing_locale_names = I18n.available_locales.reject { |locale| LanguagesHelper::HUMAN_LOCALES.key?(locale) } critical = false diff --git a/spec/controllers/admin/tags_controller_spec.rb b/spec/controllers/admin/tags_controller_spec.rb index 9145d887d..85c801a9c 100644 --- a/spec/controllers/admin/tags_controller_spec.rb +++ b/spec/controllers/admin/tags_controller_spec.rb @@ -9,18 +9,6 @@ RSpec.describe Admin::TagsController, type: :controller do sign_in Fabricate(:user, admin: true) end - describe 'GET #index' do - let!(:tag) { Fabricate(:tag) } - - before do - get :index - end - - it 'returns status 200' do - expect(response).to have_http_status(200) - end - end - describe 'GET #show' do let!(:tag) { Fabricate(:tag) } diff --git a/spec/controllers/api/v1/trends/tags_controller_spec.rb b/spec/controllers/api/v1/trends/tags_controller_spec.rb new file mode 100644 index 000000000..e2e26dcab --- /dev/null +++ b/spec/controllers/api/v1/trends/tags_controller_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Trends::TagsController, type: :controller do + render_views + + describe 'GET #index' do + before do + trending_tags = double() + + allow(trending_tags).to receive(:get).and_return(Fabricate.times(10, :tag)) + allow(Trends).to receive(:tags).and_return(trending_tags) + + get :index + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/api/v1/trends_controller_spec.rb b/spec/controllers/api/v1/trends_controller_spec.rb deleted file mode 100644 index 91e0d18fe..000000000 --- a/spec/controllers/api/v1/trends_controller_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::TrendsController, type: :controller do - render_views - - describe 'GET #index' do - before do - allow(TrendingTags).to receive(:get).and_return(Fabricate.times(10, :tag)) - get :index - end - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end -end diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb new file mode 100644 index 000000000..6db617824 --- /dev/null +++ b/spec/helpers/languages_helper_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe LanguagesHelper do + describe 'the HUMAN_LOCALES constant' do + it 'includes all I18n locales' do + expect(described_class::HUMAN_LOCALES.keys).to include(*I18n.available_locales) + end + end + + describe 'human_locale' do + it 'finds the human readable local description from a key' do + expect(helper.human_locale(:en)).to eq('English') + end + end +end diff --git a/spec/helpers/settings_helper_spec.rb b/spec/helpers/settings_helper_spec.rb deleted file mode 100644 index 092c37583..000000000 --- a/spec/helpers/settings_helper_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe SettingsHelper do - describe 'the HUMAN_LOCALES constant' do - it 'includes all I18n locales' do - options = I18n.available_locales - - expect(described_class::HUMAN_LOCALES.keys).to include(*options) - end - end - - describe 'human_locale' do - it 'finds the human readable local description from a key' do - # Ensure the value is as we expect - expect(described_class::HUMAN_LOCALES[:en]).to eq('English') - - expect(helper.human_locale(:en)).to eq('English') - end - end -end diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index 561a56b78..75ffbbf40 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -5,4 +5,14 @@ class AdminMailerPreview < ActionMailer::Preview def new_pending_account AdminMailer.new_pending_account(Account.first, User.pending.first) end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trending_tags + def new_trending_tags + AdminMailer.new_trending_tags(Account.first, Tag.limit(3)) + end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trending_links + def new_trending_links + AdminMailer.new_trending_links(Account.first, PreviewCard.limit(3)) + end end diff --git a/spec/models/trending_tags_spec.rb b/spec/models/trending_tags_spec.rb deleted file mode 100644 index dfbc7d6f8..000000000 --- a/spec/models/trending_tags_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'rails_helper' - -RSpec.describe TrendingTags do - describe '.record_use!' do - pending - end - - describe '.update!' do - let!(:at_time) { Time.now.utc } - let!(:tag1) { Fabricate(:tag, name: 'Catstodon', trendable: true) } - let!(:tag2) { Fabricate(:tag, name: 'DogsOfMastodon', trendable: true) } - let!(:tag3) { Fabricate(:tag, name: 'OCs', trendable: true) } - - before do - allow(Redis.current).to receive(:pfcount) do |key| - case key - when "activity:tags:#{tag1.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts" - 2 - when "activity:tags:#{tag1.id}:#{at_time.beginning_of_day.to_i}:accounts" - 16 - when "activity:tags:#{tag2.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts" - 0 - when "activity:tags:#{tag2.id}:#{at_time.beginning_of_day.to_i}:accounts" - 4 - when "activity:tags:#{tag3.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts" - 13 - end - end - - Redis.current.zadd('trending_tags', 0.9, tag3.id) - Redis.current.sadd("trending_tags:used:#{at_time.beginning_of_day.to_i}", [tag1.id, tag2.id]) - - tag3.update(max_score: 0.9, max_score_at: (at_time - 1.day).beginning_of_day + 12.hours) - - described_class.update!(at_time) - end - - it 'calculates and re-calculates scores' do - expect(described_class.get(10, filtered: false)).to eq [tag1, tag3] - end - - it 'omits hashtags below threshold' do - expect(described_class.get(10, filtered: false)).to_not include(tag2) - end - - it 'decays scores' do - expect(Redis.current.zscore('trending_tags', tag3.id)).to be < 0.9 - end - end - - describe '.trending?' do - let(:tag) { Fabricate(:tag) } - - before do - 10.times { |i| Redis.current.zadd('trending_tags', i + 1, Fabricate(:tag).id) } - end - - it 'returns true if the hashtag is within limit' do - Redis.current.zadd('trending_tags', 11, tag.id) - expect(described_class.trending?(tag)).to be true - end - - it 'returns false if the hashtag is outside the limit' do - Redis.current.zadd('trending_tags', 0, tag.id) - expect(described_class.trending?(tag)).to be false - end - end -end diff --git a/spec/models/trends/tags_spec.rb b/spec/models/trends/tags_spec.rb new file mode 100644 index 000000000..4f98c6aa4 --- /dev/null +++ b/spec/models/trends/tags_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe Trends::Tags do + subject { described_class.new(threshold: 5, review_threshold: 10) } + + let!(:at_time) { DateTime.new(2021, 11, 14, 10, 15, 0) } + + describe '#add' do + let(:tag) { Fabricate(:tag) } + + before do + subject.add(tag, 1, at_time) + end + + it 'records history' do + expect(tag.history.get(at_time).accounts).to eq 1 + end + + it 'records use' do + expect(subject.send(:recently_used_ids, at_time)).to eq [tag.id] + end + end + + describe '#get' do + pending + end + + describe '#refresh' do + let!(:today) { at_time } + let!(:yesterday) { today - 1.day } + + let!(:tag1) { Fabricate(:tag, name: 'Catstodon', trendable: true) } + let!(:tag2) { Fabricate(:tag, name: 'DogsOfMastodon', trendable: true) } + let!(:tag3) { Fabricate(:tag, name: 'OCs', trendable: true) } + + before do + 2.times { |i| subject.add(tag1, i, yesterday) } + 13.times { |i| subject.add(tag3, i, yesterday) } + 16.times { |i| subject.add(tag1, i, today) } + 4.times { |i| subject.add(tag2, i, today) } + end + + context do + before do + subject.refresh(yesterday + 12.hours) + subject.refresh(at_time) + end + + it 'calculates and re-calculates scores' do + expect(subject.get(false, 10)).to eq [tag1, tag3] + end + + it 'omits hashtags below threshold' do + expect(subject.get(false, 10)).to_not include(tag2) + end + end + + it 'decays scores' do + subject.refresh(yesterday + 12.hours) + original_score = subject.score(tag3.id) + expect(original_score).to eq 144.0 + subject.refresh(yesterday + 12.hours + subject.options[:max_score_halflife]) + decayed_score = subject.score(tag3.id) + expect(decayed_score).to be <= original_score / 2 + end + end +end -- cgit From d5c9feb7b7fc489afbd0a287431fe07b42451ef0 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 17 Jan 2022 00:49:55 +0100 Subject: Add support for private pinned posts (#16954) * Add support for private pinned toots * Allow local user to pin private toots * Change wording to avoid "direct message" --- app/controllers/accounts_controller.rb | 13 ++++++- .../activitypub/collections_controller.rb | 1 + .../api/v1/accounts/statuses_controller.rb | 4 +-- .../mastodon/components/status_action_bar.js | 3 +- .../features/status/components/action_bar.js | 3 +- app/lib/activitypub/activity/accept.rb | 13 +++++-- app/lib/activitypub/activity/add.rb | 3 +- .../fetch_featured_collection_service.rb | 6 +++- app/validators/status_pin_validator.rb | 2 +- app/workers/remote_account_refresh_worker.rb | 24 +++++++++++++ config/locales/en.yml | 2 +- spec/controllers/accounts_controller_spec.rb | 1 + .../activitypub/collections_controller_spec.rb | 23 ++++++++++-- .../api/v1/accounts/statuses_controller_spec.rb | 35 +++++++++++++++++- spec/lib/activitypub/activity/accept_spec.rb | 5 +++ spec/lib/activitypub/activity/add_spec.rb | 42 ++++++++++++++++++---- spec/models/status_pin_spec.rb | 4 +-- spec/validators/status_pin_validator_spec.rb | 8 ++--- 18 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 app/workers/remote_account_refresh_worker.rb (limited to 'spec/controllers/api/v1') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8210918d8..ddd38cbb0 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -28,7 +28,7 @@ class AccountsController < ApplicationController return end - @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? + @pinned_statuses = cached_filtered_status_pins if show_pinned_statuses? @statuses = cached_filtered_status_page @rss_url = rss_url @@ -64,6 +64,10 @@ class AccountsController < ApplicationController [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none? end + def filtered_pinned_statuses + @account.pinned_statuses.where(visibility: [:public, :unlisted]) + end + def filtered_statuses default_statuses.tap do |statuses| statuses.merge!(hashtag_scope) if tag_requested? @@ -142,6 +146,13 @@ class AccountsController < ApplicationController request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) end + def cached_filtered_status_pins + cache_collection( + filtered_pinned_statuses, + Status + ) + end + def cached_filtered_status_page cache_collection_paginated_by_id( filtered_statuses, diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c8b6dcc88..e4e994a98 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -21,6 +21,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController case params[:id] when 'featured' @items = for_signed_account { cache_collection(@account.pinned_statuses, Status) } + @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) } when 'tags' @items = for_signed_account { @account.featured_tags } when 'devices' diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 92ccb8061..2c027ea76 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -46,9 +46,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def pinned_scope - return Status.none if @account.blocking?(current_account) - - @account.pinned_statuses + @account.pinned_statuses.permitted_for(@account, current_account) end def no_replies_scope diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 85c76edee..d125359e9 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -225,6 +225,7 @@ class StatusActionBar extends ImmutablePureComponent { const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); const mutingConversation = status.get('muted'); const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; @@ -242,7 +243,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); - if (writtenByMe && publicStatus) { + if (writtenByMe && pinnableStatus) { menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); } diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index ffa2510c0..e60119bc4 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -188,6 +188,7 @@ class ActionBar extends React.PureComponent { const { status, relationship, intl } = this.props; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); const mutingConversation = status.get('muted'); const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; @@ -201,7 +202,7 @@ class ActionBar extends React.PureComponent { } if (writtenByMe) { - if (publicStatus) { + if (pinnableStatus) { menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push(null); } diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index 7010ff43e..5126e23c6 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -3,7 +3,7 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity def perform return accept_follow_for_relay if relay_follow? - return follow_request_from_object.authorize! unless follow_request_from_object.nil? + return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil? case @object['type'] when 'Follow' @@ -19,7 +19,16 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity return if target_account.nil? || !target_account.local? follow_request = FollowRequest.find_by(account: target_account, target_account: @account) - follow_request&.authorize! + accept_follow!(follow_request) + end + + def accept_follow!(request) + return if request.nil? + + is_first_follow = !request.target_account.followers.local.exists? + request.authorize! + + RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow end def accept_follow_for_relay diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb index 688ab00b3..845eeaef7 100644 --- a/app/lib/activitypub/activity/add.rb +++ b/app/lib/activitypub/activity/add.rb @@ -4,8 +4,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity def perform return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url - status = status_from_uri(object_uri) - status ||= fetch_remote_original_status + status = status_from_object return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status) diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb index 72352aca6..9fce478c1 100644 --- a/app/services/activitypub/fetch_featured_collection_service.rb +++ b/app/services/activitypub/fetch_featured_collection_service.rb @@ -23,7 +23,7 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService def process_items(items) status_ids = items.map { |item| value_or_id(item) } - .filter_map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) unless ActivityPub::TagManager.instance.local_uri?(uri) } + .filter_map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: local_follower) unless ActivityPub::TagManager.instance.local_uri?(uri) } .filter_map { |status| status.id if status.account_id == @account.id } to_remove = [] to_add = status_ids @@ -46,4 +46,8 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService def supported_context? super(@json) end + + def local_follower + @local_follower ||= account.followers.local.without_suspended.first + end end diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb index 2c7bce674..2fdd5b34f 100644 --- a/app/validators/status_pin_validator.rb +++ b/app/validators/status_pin_validator.rb @@ -4,7 +4,7 @@ class StatusPinValidator < ActiveModel::Validator def validate(pin) pin.errors.add(:base, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog? pin.errors.add(:base, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id - pin.errors.add(:base, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility) + pin.errors.add(:base, I18n.t('statuses.pin_errors.direct')) if pin.status.direct_visibility? pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count > 4 && pin.account.local? end end diff --git a/app/workers/remote_account_refresh_worker.rb b/app/workers/remote_account_refresh_worker.rb new file mode 100644 index 000000000..9632936b5 --- /dev/null +++ b/app/workers/remote_account_refresh_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RemoteAccountRefreshWorker + include Sidekiq::Worker + include ExponentialBackoff + include JsonLdHelper + + sidekiq_options queue: 'pull', retry: 3 + + def perform(id) + account = Account.find_by(id: id) + return if account.nil? || account.local? + + ActivityPub::FetchRemoteAccountService.new.call(account.uri) + rescue Mastodon::UnexpectedResponseError => e + response = e.response + + if response_error_unsalvageable?(response) + # Give up + else + raise e + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 32b48dbff..693a7b400 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1300,9 +1300,9 @@ en: open_in_web: Open in web over_character_limit: character limit of %{max} exceeded pin_errors: + direct: Posts that are only visible to mentioned users cannot be pinned limit: You have already pinned the maximum number of posts ownership: Someone else's post cannot be pinned - private: Non-public posts cannot be pinned reblog: A boost cannot be pinned poll: total_people: diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index ac426b01e..7c5ba8754 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -35,6 +35,7 @@ RSpec.describe AccountsController, type: :controller do before do status_media.media_attachments << Fabricate(:media_attachment, account: account, type: :image) account.pinned_statuses << status_pinned + account.pinned_statuses << status_private end shared_examples 'preliminary checks' do diff --git a/spec/controllers/activitypub/collections_controller_spec.rb b/spec/controllers/activitypub/collections_controller_spec.rb index d584136ff..21a033945 100644 --- a/spec/controllers/activitypub/collections_controller_spec.rb +++ b/spec/controllers/activitypub/collections_controller_spec.rb @@ -4,6 +4,7 @@ require 'rails_helper' RSpec.describe ActivityPub::CollectionsController, type: :controller do let!(:account) { Fabricate(:account) } + let!(:private_pinned) { Fabricate(:status, account: account, text: 'secret private stuff', visibility: :private) } let(:remote_account) { nil } shared_examples 'cachable response' do @@ -27,6 +28,7 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do Fabricate(:status_pin, account: account) Fabricate(:status_pin, account: account) + Fabricate(:status_pin, account: account, status: private_pinned) Fabricate(:status, account: account, visibility: :private) end @@ -50,7 +52,15 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do it 'returns orderedItems with pinned statuses' do expect(body[:orderedItems]).to be_an Array - expect(body[:orderedItems].size).to eq 2 + expect(body[:orderedItems].size).to eq 3 + end + + it 'includes URI of private pinned status' do + expect(body[:orderedItems]).to include(ActivityPub::TagManager.instance.uri_for(private_pinned)) + end + + it 'does not include contents of private pinned status' do + expect(response.body).not_to include(private_pinned.text) end context 'when account is permanently suspended' do @@ -96,7 +106,16 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do it 'returns orderedItems with pinned statuses' do json = body_as_json expect(json[:orderedItems]).to be_an Array - expect(json[:orderedItems].size).to eq 2 + expect(json[:orderedItems].size).to eq 3 + end + + it 'includes URI of private pinned status' do + json = body_as_json + expect(json[:orderedItems]).to include(ActivityPub::TagManager.instance.uri_for(private_pinned)) + end + + it 'does not include contents of private pinned status' do + expect(response.body).not_to include(private_pinned.text) end end diff --git a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb index 693cd1ac6..0a18ddcbd 100644 --- a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb @@ -39,7 +39,7 @@ describe Api::V1::Accounts::StatusesController do end end - context 'with only pinned' do + context 'with only own pinned' do before do Fabricate(:status_pin, account: user.account, status: Fabricate(:status, account: user.account)) end @@ -50,5 +50,38 @@ describe Api::V1::Accounts::StatusesController do expect(response).to have_http_status(200) end end + + context "with someone else's pinned statuses" do + let(:account) { Fabricate(:account, username: 'bob', domain: 'example.com') } + let(:status) { Fabricate(:status, account: account) } + let(:private_status) { Fabricate(:status, account: account, visibility: :private) } + let!(:pin) { Fabricate(:status_pin, account: account, status: status) } + let!(:private_pin) { Fabricate(:status_pin, account: account, status: private_status) } + + it 'returns http success' do + get :index, params: { account_id: account.id, pinned: true } + expect(response).to have_http_status(200) + end + + context 'when user does not follow account' do + it 'lists the public status only' do + get :index, params: { account_id: account.id, pinned: true } + json = body_as_json + expect(json.map { |item| item[:id].to_i }).to eq [status.id] + end + end + + context 'when user follows account' do + before do + user.account.follow!(account) + end + + it 'lists both the public and the private statuses' do + get :index, params: { account_id: account.id, pinned: true } + json = body_as_json + expect(json.map { |item| item[:id].to_i }.sort).to eq [status.id, private_status.id].sort + end + end + end end end diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb index 883bab6ac..304cf2208 100644 --- a/spec/lib/activitypub/activity/accept_spec.rb +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -23,6 +23,7 @@ RSpec.describe ActivityPub::Activity::Accept do subject { described_class.new(json, sender) } before do + allow(RemoteAccountRefreshWorker).to receive(:perform_async) Fabricate(:follow_request, account: recipient, target_account: sender) subject.perform end @@ -34,6 +35,10 @@ RSpec.describe ActivityPub::Activity::Accept do it 'removes the follow request' do expect(recipient.requested?(sender)).to be false end + + it 'queues a refresh' do + expect(RemoteAccountRefreshWorker).to have_received(:perform_async).with(sender.id) + end end context 'given a relay' do diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb index 16db71c88..e6408b610 100644 --- a/spec/lib/activitypub/activity/add_spec.rb +++ b/spec/lib/activitypub/activity/add_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Add do - let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured') } - let(:status) { Fabricate(:status, account: sender) } + let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured', domain: 'example.com') } + let(:status) { Fabricate(:status, account: sender, visibility: :private) } let(:json) do { @@ -24,6 +24,8 @@ RSpec.describe ActivityPub::Activity::Add do end context 'when status was not known before' do + let(:service_stub) { double } + let(:json) do { '@context': 'https://www.w3.org/ns/activitystreams', @@ -36,12 +38,40 @@ RSpec.describe ActivityPub::Activity::Add do end before do - stub_request(:get, 'https://example.com/unknown').to_return(status: 410) + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_stub) + end + + context 'when there is a local follower' do + before do + account = Fabricate(:account) + account.follow!(sender) + end + + it 'fetches the status and pins it' do + allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil| + expect(uri).to eq 'https://example.com/unknown' + expect(id).to eq true + expect(on_behalf_of&.following?(sender)).to eq true + status + end + subject.perform + expect(service_stub).to have_received(:call) + expect(sender.pinned?(status)).to be true + end end - it 'fetches the status' do - subject.perform - expect(a_request(:get, 'https://example.com/unknown')).to have_been_made.at_least_once + context 'when there is no local follower' do + it 'tries to fetch the status' do + allow(service_stub).to receive(:call) do |uri, id: true, on_behalf_of: nil| + expect(uri).to eq 'https://example.com/unknown' + expect(id).to eq true + expect(on_behalf_of).to eq nil + nil + end + subject.perform + expect(service_stub).to have_received(:call) + expect(sender.pinned?(status)).to be false + end end end end diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb index 6f0b2feb8..c18faca78 100644 --- a/spec/models/status_pin_spec.rb +++ b/spec/models/status_pin_spec.rb @@ -24,11 +24,11 @@ RSpec.describe StatusPin, type: :model do expect(StatusPin.new(account: account, status: reblog).save).to be false end - it 'does not allow pins of private statuses' do + it 'does allow pins of direct statuses' do account = Fabricate(:account) status = Fabricate(:status, account: account, visibility: :private) - expect(StatusPin.new(account: account, status: status).save).to be false + expect(StatusPin.new(account: account, status: status).save).to be true end it 'does not allow pins of direct statuses' do diff --git a/spec/validators/status_pin_validator_spec.rb b/spec/validators/status_pin_validator_spec.rb index 06532e5b3..d5bd0d1b8 100644 --- a/spec/validators/status_pin_validator_spec.rb +++ b/spec/validators/status_pin_validator_spec.rb @@ -9,7 +9,7 @@ RSpec.describe StatusPinValidator, type: :validator do end let(:pin) { double(account: account, errors: errors, status: status, account_id: pin_account_id) } - let(:status) { double(reblog?: reblog, account_id: status_account_id, visibility: visibility) } + let(:status) { double(reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') } let(:account) { double(status_pins: status_pins, local?: local) } let(:status_pins) { double(count: count) } let(:errors) { double(add: nil) } @@ -37,11 +37,11 @@ RSpec.describe StatusPinValidator, type: :validator do end end - context 'unless %w(public unlisted).include?(pin.status.visibility)' do - let(:visibility) { '' } + context 'if pin.status.direct_visibility?' do + let(:visibility) { 'direct' } it 'calls errors.add' do - expect(errors).to have_received(:add).with(:base, I18n.t('statuses.pin_errors.private')) + expect(errors).to have_received(:add).with(:base, I18n.t('statuses.pin_errors.direct')) end end -- cgit From 1060666c583670bb3b89ed5154e61038331e30c3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 19 Jan 2022 22:37:27 +0100 Subject: Add support for editing for published statuses (#16697) * Add support for editing for published statuses * Fix references to stripped-out code * Various fixes and improvements * Further fixes and improvements * Fix updates being potentially sent to unauthorized recipients * Various fixes and improvements * Fix wrong words in test * Fix notifying accounts that were tagged but were not in the audience * Fix mistake --- .../api/v1/statuses/histories_controller.rb | 21 ++ .../api/v1/statuses/sources_controller.rb | 21 ++ app/helpers/jsonld_helper.rb | 8 +- .../mastodon/actions/importer/normalizer.js | 7 +- app/javascript/mastodon/actions/statuses.js | 3 + app/javascript/mastodon/actions/streaming.js | 4 + app/javascript/mastodon/components/status.js | 3 +- .../features/status/components/detailed_status.js | 14 +- app/javascript/styles/mastodon/components.scss | 11 + app/lib/activitypub/activity.rb | 43 ---- app/lib/activitypub/activity/announce.rb | 18 +- app/lib/activitypub/activity/create.rb | 249 +++++++------------ app/lib/activitypub/activity/update.rb | 17 +- app/lib/activitypub/parser/custom_emoji_parser.rb | 27 ++ .../activitypub/parser/media_attachment_parser.rb | 58 +++++ app/lib/activitypub/parser/poll_parser.rb | 53 ++++ app/lib/activitypub/parser/status_parser.rb | 118 +++++++++ app/lib/feed_manager.rb | 20 +- app/lib/status_reach_finder.rb | 31 ++- app/models/poll.rb | 1 + app/models/status.rb | 7 + app/models/status_edit.rb | 23 ++ app/serializers/activitypub/note_serializer.rb | 7 + app/serializers/rest/status_edit_serializer.rb | 6 + app/serializers/rest/status_serializer.rb | 2 +- app/serializers/rest/status_source_serializer.rb | 9 + .../activitypub/fetch_remote_poll_service.rb | 2 +- app/services/activitypub/process_poll_service.rb | 64 ----- .../activitypub/process_status_update_service.rb | 275 +++++++++++++++++++++ app/services/fan_out_on_write_service.rb | 149 ++++++----- app/services/process_mentions_service.rb | 65 ++--- app/services/remove_status_service.rb | 2 +- app/workers/activitypub/distribution_worker.rb | 48 +--- app/workers/activitypub/raw_distribution_worker.rb | 37 ++- .../activitypub/reply_distribution_worker.rb | 34 --- .../activitypub/update_distribution_worker.rb | 25 +- app/workers/distribution_worker.rb | 4 +- app/workers/feed_insert_worker.rb | 34 ++- app/workers/local_notification_worker.rb | 2 + app/workers/poll_expiration_notify_worker.rb | 45 +++- app/workers/push_update_worker.rb | 35 ++- config/routes.rb | 3 + .../20210904215403_add_edited_at_to_statuses.rb | 5 + db/migrate/20210908220918_create_status_edits.rb | 13 + db/schema.rb | 15 ++ .../api/v1/statuses/histories_controller_spec.rb | 29 +++ .../api/v1/statuses/sources_controller_spec.rb | 29 +++ spec/fabricators/preview_card_fabricator.rb | 6 + spec/fabricators/status_edit_fabricator.rb | 7 + spec/lib/status_reach_finder_spec.rb | 109 ++++++++ spec/models/status_edit_spec.rb | 5 + .../fetch_remote_status_service_spec.rb | 6 +- spec/services/fan_out_on_write_service_spec.rb | 107 ++++++-- spec/services/process_mentions_service_spec.rb | 32 +-- .../activitypub/distribution_worker_spec.rb | 7 +- spec/workers/feed_insert_worker_spec.rb | 2 +- 56 files changed, 1409 insertions(+), 568 deletions(-) create mode 100644 app/controllers/api/v1/statuses/histories_controller.rb create mode 100644 app/controllers/api/v1/statuses/sources_controller.rb create mode 100644 app/lib/activitypub/parser/custom_emoji_parser.rb create mode 100644 app/lib/activitypub/parser/media_attachment_parser.rb create mode 100644 app/lib/activitypub/parser/poll_parser.rb create mode 100644 app/lib/activitypub/parser/status_parser.rb create mode 100644 app/models/status_edit.rb create mode 100644 app/serializers/rest/status_edit_serializer.rb create mode 100644 app/serializers/rest/status_source_serializer.rb delete mode 100644 app/services/activitypub/process_poll_service.rb create mode 100644 app/services/activitypub/process_status_update_service.rb delete mode 100644 app/workers/activitypub/reply_distribution_worker.rb create mode 100644 db/migrate/20210904215403_add_edited_at_to_statuses.rb create mode 100644 db/migrate/20210908220918_create_status_edits.rb create mode 100644 spec/controllers/api/v1/statuses/histories_controller_spec.rb create mode 100644 spec/controllers/api/v1/statuses/sources_controller_spec.rb create mode 100644 spec/fabricators/preview_card_fabricator.rb create mode 100644 spec/fabricators/status_edit_fabricator.rb create mode 100644 spec/lib/status_reach_finder_spec.rb create mode 100644 spec/models/status_edit_spec.rb (limited to 'spec/controllers/api/v1') diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb new file mode 100644 index 000000000..c2c1fac5d --- /dev/null +++ b/app/controllers/api/v1/statuses/histories_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::HistoriesController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :set_status + + def show + render json: @status.edits, each_serializer: REST::StatusEditSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/statuses/sources_controller.rb b/app/controllers/api/v1/statuses/sources_controller.rb new file mode 100644 index 000000000..434086451 --- /dev/null +++ b/app/controllers/api/v1/statuses/sources_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::SourcesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read, :'read:statuses' } + before_action :set_status + + def show + render json: @status, serializer: REST::StatusSourceSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 62eb50f78..c24d2ddf1 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -34,7 +34,13 @@ module JsonLdHelper end def as_array(value) - value.is_a?(Array) ? value : [value] + if value.nil? + [] + elsif value.is_a?(Array) + value + else + [value] + end end def value_or_id(value) diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 6b79e1f16..ca76e3494 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -54,9 +54,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } - // Only calculate these values when status first encountered - // Otherwise keep the ones already in the reducer - if (normalOldStatus) { + // Only calculate these values when status first encountered and + // when the underlying values change. Otherwise keep the ones + // already in the reducer + if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) { normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3fc7c0702..20d71362e 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -131,6 +131,9 @@ export function deleteStatusFail(id, error) { }; }; +export const updateStatus = status => dispatch => + dispatch(importFetchedStatus(status)); + export function fetchContext(id) { return (dispatch, getState) => { dispatch(fetchContextRequest(id)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index beb5c6a4a..8fbb22271 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,6 +10,7 @@ import { } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; import { updateConversations } from './conversations'; +import { updateStatus } from './statuses'; import { fetchAnnouncements, updateAnnouncements, @@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'update': dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); break; + case 'status.update': + dispatch(updateStatus(JSON.parse(data.payload))); + break; case 'delete': dispatch(deleteFromTimelines(data.payload)); break; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9955046c0..fb370ca71 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -57,6 +57,7 @@ const messages = defineMessages({ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, }); export default @injectIntl @@ -483,7 +484,7 @@ class Status extends ImmutablePureComponent {
- + {status.get('edited_at') && *} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 72ddeb2b2..ee4a6b989 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name'; import StatusContent from '../../../components/status_content'; import MediaGallery from '../../../components/media_gallery'; import { Link } from 'react-router-dom'; -import { injectIntl, defineMessages, FormattedDate } from 'react-intl'; +import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from '../../video'; @@ -116,6 +116,7 @@ class DetailedStatus extends ImmutablePureComponent { let reblogLink = ''; let reblogIcon = 'retweet'; let favouriteLink = ''; + let edited = ''; if (this.props.measureHeight) { outerStyle.height = `${this.state.height}px`; @@ -237,6 +238,15 @@ class DetailedStatus extends ImmutablePureComponent { ); } + if (status.get('edited_at')) { + edited = ( + + · + + + ); + } + return (
@@ -252,7 +262,7 @@ class DetailedStatus extends ImmutablePureComponent {
- {visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} + {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0a62e6b82..02b3473a9 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -967,6 +967,17 @@ } } +.status__content__edited-label { + display: block; + cursor: default; + font-size: 15px; + line-height: 20px; + padding: 0; + padding-top: 8px; + color: $dark-text-color; + font-weight: 500; +} + .status__content__spoiler-link { display: inline-block; border-radius: 2px; diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 3aeecb4ec..706960f92 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -94,49 +94,6 @@ class ActivityPub::Activity equals_or_includes_any?(@object['type'], CONVERTED_TYPES) end - def distribute(status) - crawl_links(status) - - notify_about_reblog(status) if reblog_of_local_account?(status) && !reblog_by_following_group_account?(status) - notify_about_mentions(status) - - # Only continue if the status is supposed to have arrived in real-time. - # Note that if @options[:override_timestamps] isn't set, the status - # may have a lower snowflake id than other existing statuses, potentially - # "hiding" it from paginated API calls - return unless @options[:override_timestamps] || status.within_realtime_window? - - distribute_to_followers(status) - end - - def reblog_of_local_account?(status) - status.reblog? && status.reblog.account.local? - end - - def reblog_by_following_group_account?(status) - status.reblog? && status.account.group? && status.reblog.account.following?(status.account) - end - - def notify_about_reblog(status) - NotifyService.new.call(status.reblog.account, :reblog, status) - end - - def notify_about_mentions(status) - status.active_mentions.includes(:account).each do |mention| - next unless mention.account.local? && audience_includes?(mention.account) - NotifyService.new.call(mention.account, :mention, mention) - end - end - - def crawl_links(status) - # Spread out crawling randomly to avoid DDoSing the link - LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id) - end - - def distribute_to_followers(status) - ::DistributionWorker.perform_async(status.id) - end - def delete_arrived_first?(uri) redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}") end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 6c5d88d18..1f9319290 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -25,7 +25,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity Trends.tags.register(@status) Trends.links.register(@status) - distribute(@status) + distribute end @status @@ -33,6 +33,22 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity private + def distribute + # Notify the author of the original status if that status is local + NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status) + + # Distribute into home and list feeds + ::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window? + end + + def reblog_of_local_account?(status) + status.reblog? && status.reblog.account.local? + end + + def reblog_by_following_group_account?(status) + status.reblog? && status.account.group? && status.reblog.account.following?(status.account) + end + def audience_to as_array(@json['to']).map { |x| value_or_id(x) } end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 8a0dc9d33..a861c34bc 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_status - @tags = [] - @mentions = [] - @params = {} + @tags = [] + @mentions = [] + @silenced_account_ids = [] + @params = {} process_status_params process_tags @@ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity resolve_thread(@status) fetch_replies(@status) - distribute(@status) + distribute forward_for_reply end + def distribute + # Spread out crawling randomly to avoid DDoSing the link + LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id) + + # Distribute into home and list feeds and notify mentioned accounts + ::DistributionWorker.perform_async(@status.id, silenced_account_ids: @silenced_account_ids) if @options[:override_timestamps] || @status.within_realtime_window? + end + def find_existing_status status = status_from_uri(object_uri) status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? @@ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_status_params + @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url) + @params = begin { - uri: object_uri, - url: object_url || object_uri, + uri: @status_parser.uri, + url: @status_parser.url || @status_parser.uri, account: @account, - text: text_from_content || '', - language: detected_language, - spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), - created_at: @object['published'], + text: converted_object_type? ? converted_text : (@status_parser.text || ''), + language: @status_parser.language || detected_language, + spoiler_text: converted_object_type? ? '' : (@status_parser.spoiler_text || ''), + created_at: @status_parser.created_at, + edited_at: @status_parser.edited_at, override_timestamps: @options[:override_timestamps], - reply: @object['inReplyTo'].present?, - sensitive: @account.sensitized? || @object['sensitive'] || false, - visibility: visibility_from_audience, + reply: @status_parser.reply, + sensitive: @account.sensitized? || @status_parser.sensitive || false, + visibility: @status_parser.visibility, thread: replied_to_status, conversation: conversation_from_uri(@object['conversation']), media_attachment_ids: process_attachments.take(4).map(&:id), @@ -117,42 +129,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_audience - (audience_to + audience_cc).uniq.each do |audience| - next if ActivityPub::TagManager.instance.public_collection?(audience) + # Unlike with tags, there is no point in resolving accounts we don't already + # know here, because silent mentions would only be used for local access control anyway + accounts_in_audience = (audience_to + audience_cc).uniq.filter_map do |audience| + account_from_uri(audience) unless ActivityPub::TagManager.instance.public_collection?(audience) + end - # Unlike with tags, there is no point in resolving accounts we don't already - # know here, because silent mentions would only be used for local access - # control anyway - account = account_from_uri(audience) + # If the payload was delivered to a specific inbox, the inbox owner must have + # access to it, unless they already have access to it anyway + if @options[:delivered_to_account_id] + accounts_in_audience << delivered_to_account + accounts_in_audience.uniq! + end - next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id } + accounts_in_audience.each do |account| + # This runs after tags are processed, and those translate into non-silent + # mentions, which take precedence + next if @mentions.any? { |mention| mention.account_id == account.id } @mentions << Mention.new(account: account, silent: true) # If there is at least one silent mention, then the status can be considered # as a limited-audience status, and not strictly a direct message, but only # if we considered a direct message in the first place - next unless @params[:visibility] == :direct - - @params[:visibility] = :limited + @params[:visibility] = :limited if @params[:visibility] == :direct end - # If the payload was delivered to a specific inbox, the inbox owner must have - # access to it, unless they already have access to it anyway - return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] } - - @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true) - - return unless @params[:visibility] == :direct - - @params[:visibility] = :limited + # Accounts that are tagged but are not in the audience are not + # supposed to be notified explicitly + @silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id) end def postprocess_audience_and_deliver return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id]) - delivered_to_account = Account.find(@options[:delivered_to_account_id]) - @status.mentions.create(account: delivered_to_account, silent: true) @status.update(visibility: :limited) if @status.direct_visibility? @@ -161,6 +171,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home) end + def delivered_to_account + @delivered_to_account ||= Account.find(@options[:delivered_to_account_id]) + end + def attach_tags(status) @tags.each do |tag| status.tags << tag @@ -215,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_emoji(tag) return if skip_download? - return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank? - shortcode = tag['name'].delete(':') - image_url = tag['icon']['url'] - uri = tag['id'] - updated = tag['updated'] - emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain) + custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag) + + return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? - return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at) + emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain) - emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri) - emoji.image_remote_url = image_url - emoji.save - rescue Seahorse::Client::NetworkingError => e - Rails.logger.warn "Error storing emoji: #{e}" + return unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at) + + begin + emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri) + emoji.image_remote_url = custom_emoji_parser.image_remote_url + emoji.save + rescue Seahorse::Client::NetworkingError => e + Rails.logger.warn "Error storing emoji: #{e}" + end end def process_attachments @@ -238,14 +253,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachments = [] as_array(@object['attachment']).each do |attachment| - next if attachment['url'].blank? || media_attachments.size >= 4 + media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) + + next if media_attachment_parser.remote_url.blank? || media_attachments.size >= 4 begin - href = Addressable::URI.parse(attachment['url']).normalize.to_s - media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil) + media_attachment = MediaAttachment.create( + account: @account, + remote_url: media_attachment_parser.remote_url, + thumbnail_remote_url: media_attachment_parser.thumbnail_remote_url, + description: media_attachment_parser.description, + focus: media_attachment_parser.focus, + blurhash: media_attachment_parser.blurhash + ) + media_attachments << media_attachment - next if unsupported_media_type?(attachment['mediaType']) || skip_download? + next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download? media_attachment.download_file! media_attachment.download_thumbnail! @@ -263,42 +287,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachments end - def icon_url_from_attachment(attachment) - url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon'] - Addressable::URI.parse(url).normalize.to_s if url.present? - rescue Addressable::URI::InvalidURIError - nil - end - def process_poll - return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array)) - - expires_at = begin - if @object['closed'].is_a?(String) - @object['closed'] - elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) - Time.now.utc - else - @object['endTime'] - end - end - - if @object['anyOf'].is_a?(Array) - multiple = true - items = @object['anyOf'] - else - multiple = false - items = @object['oneOf'] - end + poll_parser = ActivityPub::Parser::PollParser.new(@object) - voters_count = @object['votersCount'] + return unless poll_parser.valid? @account.polls.new( - multiple: multiple, - expires_at: expires_at, - options: items.map { |item| item['name'].presence || item['content'] }.compact, - cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }, - voters_count: voters_count + multiple: poll_parser.multiple, + expires_at: poll_parser.expires_at, + options: poll_parser.options, + cached_tallies: poll_parser.cached_tallies, + voters_count: poll_parser.voters_count ) end @@ -351,23 +350,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end - def visibility_from_audience - if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) } - :public - elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } - :unlisted - elsif audience_to.include?(@account.followers_url) - :private - else - :direct - end - end - - def audience_includes?(account) - uri = ActivityPub::TagManager.instance.uri_for(account) - audience_to.include?(uri) || audience_cc.include?(uri) - end - def replied_to_status return @replied_to_status if defined?(@replied_to_status) @@ -384,81 +366,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity value_or_id(@object['inReplyTo']) end - def text_from_content - return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type? - - if @object['content'].present? - @object['content'] - elsif content_language_map? - @object['contentMap'].values.first - end - end - - def text_from_summary - if @object['summary'].present? - @object['summary'] - elsif summary_language_map? - @object['summaryMap'].values.first - end - end - - def text_from_name - if @object['name'].present? - @object['name'] - elsif name_language_map? - @object['nameMap'].values.first - end + def converted_text + Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n")) end def detected_language - if content_language_map? - @object['contentMap'].keys.first - elsif name_language_map? - @object['nameMap'].keys.first - elsif summary_language_map? - @object['summaryMap'].keys.first - elsif supported_object_type? - LanguageDetector.instance.detect(text_from_content, @account) - end - end - - def object_url - return if @object['url'].blank? - - url_candidate = url_to_href(@object['url'], 'text/html') - - if invalid_origin?(url_candidate) - nil - else - url_candidate - end - end - - def summary_language_map? - @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? - end - - def content_language_map? - @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? - end - - def name_language_map? - @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? + LanguageDetector.instance.detect(@status_parser.text, @account) if supported_object_type? end def unsupported_media_type?(mime_type) mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) end - def supported_blurhash?(blurhash) - components = blurhash.blank? || !blurhash_valid_chars?(blurhash) ? nil : Blurhash.components(blurhash) - components.present? && components.none? { |comp| comp > 5 } - end - - def blurhash_valid_chars?(blurhash) - /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash) - end - def skip_download? return @skip_download if defined?(@skip_download) diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb index 018e2df54..f04ad321b 100644 --- a/app/lib/activitypub/activity/update.rb +++ b/app/lib/activitypub/activity/update.rb @@ -1,32 +1,31 @@ # frozen_string_literal: true class ActivityPub::Activity::Update < ActivityPub::Activity - SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze - def perform dereference_object! - if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) + if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service)) update_account - elsif equals_or_includes_any?(@object['type'], %w(Question)) - update_poll + elsif equals_or_includes_any?(@object['type'], %w(Note Question)) + update_status end end private def update_account - return if @account.uri != object_uri + return reject_payload! if @account.uri != object_uri ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true) end - def update_poll + def update_status return reject_payload! if invalid_origin?(@object['id']) status = Status.find_by(uri: object_uri, account_id: @account.id) - return if status.nil? || status.preloadable_poll.nil? - ActivityPub::ProcessPollService.new.call(status.preloadable_poll, @object) + return if status.nil? + + ActivityPub::ProcessStatusUpdateService.new.call(status, @object) end end diff --git a/app/lib/activitypub/parser/custom_emoji_parser.rb b/app/lib/activitypub/parser/custom_emoji_parser.rb new file mode 100644 index 000000000..724c60215 --- /dev/null +++ b/app/lib/activitypub/parser/custom_emoji_parser.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ActivityPub::Parser::CustomEmojiParser + include JsonLdHelper + + def initialize(json) + @json = json + end + + def uri + @json['id'] + end + + def shortcode + @json['name']&.delete(':') + end + + def image_remote_url + @json.dig('icon', 'url') + end + + def updated_at + @json['updated']&.to_datetime + rescue ArgumentError + nil + end +end diff --git a/app/lib/activitypub/parser/media_attachment_parser.rb b/app/lib/activitypub/parser/media_attachment_parser.rb new file mode 100644 index 000000000..1798e58a4 --- /dev/null +++ b/app/lib/activitypub/parser/media_attachment_parser.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class ActivityPub::Parser::MediaAttachmentParser + include JsonLdHelper + + def initialize(json) + @json = json + end + + # @param [MediaAttachment] previous_record + def significantly_changes?(previous_record) + remote_url != previous_record.remote_url || + thumbnail_remote_url != previous_record.thumbnail_remote_url || + description != previous_record.description + end + + def remote_url + Addressable::URI.parse(@json['url'])&.normalize&.to_s + rescue Addressable::URI::InvalidURIError + nil + end + + def thumbnail_remote_url + Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s + rescue Addressable::URI::InvalidURIError + nil + end + + def description + @json['summary'].presence || @json['name'].presence + end + + def focus + @json['focalPoint'] + end + + def blurhash + supported_blurhash? ? @json['blurhash'] : nil + end + + def file_content_type + @json['mediaType'] + end + + private + + def supported_blurhash? + components = begin + blurhash = @json['blurhash'] + + if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash) + Blurhash.components(blurhash) + end + end + + components.present? && components.none? { |comp| comp > 5 } + end +end diff --git a/app/lib/activitypub/parser/poll_parser.rb b/app/lib/activitypub/parser/poll_parser.rb new file mode 100644 index 000000000..758c03f07 --- /dev/null +++ b/app/lib/activitypub/parser/poll_parser.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class ActivityPub::Parser::PollParser + include JsonLdHelper + + def initialize(json) + @json = json + end + + def valid? + equals_or_includes?(@json['type'], 'Question') && items.is_a?(Array) + end + + # @param [Poll] previous_record + def significantly_changes?(previous_record) + options != previous_record.options || + multiple != previous_record.multiple + end + + def options + items.filter_map { |item| item['name'].presence || item['content'] } + end + + def multiple + @json['anyOf'].is_a?(Array) + end + + def expires_at + if @json['closed'].is_a?(String) + @json['closed'].to_datetime + elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) + Time.now.utc + else + @json['endTime']&.to_datetime + end + rescue ArgumentError + nil + end + + def voters_count + @json['votersCount'] + end + + def cached_tallies + items.map { |item| item.dig('replies', 'totalItems') || 0 } + end + + private + + def items + @json['anyOf'] || @json['oneOf'] + end +end diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb new file mode 100644 index 000000000..3ba154d01 --- /dev/null +++ b/app/lib/activitypub/parser/status_parser.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +class ActivityPub::Parser::StatusParser + include JsonLdHelper + + # @param [Hash] json + # @param [Hash] magic_values + # @option magic_values [String] :followers_collection + def initialize(json, magic_values = {}) + @json = json + @object = json['object'] || json + @magic_values = magic_values + end + + def uri + id = @object['id'] + + if id&.start_with?('bear:') + Addressable::URI.parse(id).query_values['u'] + else + id + end + rescue Addressable::URI::InvalidURIError + id + end + + def url + url_to_href(@object['url'], 'text/html') if @object['url'].present? + end + + def text + if @object['content'].present? + @object['content'] + elsif content_language_map? + @object['contentMap'].values.first + end + end + + def spoiler_text + if @object['summary'].present? + @object['summary'] + elsif summary_language_map? + @object['summaryMap'].values.first + end + end + + def title + if @object['name'].present? + @object['name'] + elsif name_language_map? + @object['nameMap'].values.first + end + end + + def created_at + @object['published']&.to_datetime + rescue ArgumentError + nil + end + + def edited_at + @object['updated']&.to_datetime + rescue ArgumentError + nil + end + + def reply + @object['inReplyTo'].present? + end + + def sensitive + @object['sensitive'] + end + + def visibility + if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) } + :public + elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } + :unlisted + elsif audience_to.include?(@magic_values[:followers_collection]) + :private + else + :direct + end + end + + def language + if content_language_map? + @object['contentMap'].keys.first + elsif name_language_map? + @object['nameMap'].keys.first + elsif summary_language_map? + @object['summaryMap'].keys.first + end + end + + private + + def audience_to + as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) } + end + + def audience_cc + as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } + end + + def summary_language_map? + @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? + end + + def content_language_map? + @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? + end + + def name_language_map? + @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index d5e435216..c4dd9d00f 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -53,46 +53,50 @@ class FeedManager # Add a status to a home feed and send a streaming API update # @param [Account] account # @param [Status] status + # @param [Boolean] update # @return [Boolean] - def push_to_home(account, status) + def push_to_home(account, status, update: false) return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) trim(:home, account.id) - PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}") + PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", update: update) if push_update_required?("timeline:#{account.id}") true end # Remove a status from a home feed and send a streaming API update # @param [Account] account # @param [Status] status + # @param [Boolean] update # @return [Boolean] - def unpush_from_home(account, status) + def unpush_from_home(account, status, update: false) return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) - redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update true end # Add a status to a list feed and send a streaming API update # @param [List] list # @param [Status] status + # @param [Boolean] update # @return [Boolean] - def push_to_list(list, status) + def push_to_list(list, status, update: false) return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) trim(:list, list.id) - PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") + PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", update: update) if push_update_required?("timeline:list:#{list.id}") true end # Remove a status from a list feed and send a streaming API update # @param [List] list # @param [Status] status + # @param [Boolean] update # @return [Boolean] - def unpush_from_list(list, status) + def unpush_from_list(list, status, update: false) return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) - redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update true end diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 735d66a4f..98e502bb6 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class StatusReachFinder - def initialize(status) - @status = status + # @param [Status] status + # @param [Hash] options + # @option options [Boolean] :unsafe + def initialize(status, options = {}) + @status = status + @options = options end def inboxes @@ -38,7 +42,7 @@ class StatusReachFinder end def replied_to_account_id - @status.in_reply_to_account_id + @status.in_reply_to_account_id if distributable? end def reblog_of_account_id @@ -49,21 +53,26 @@ class StatusReachFinder @status.mentions.pluck(:account_id) end + # Beware: Reblogs can be created without the author having had access to the status def reblogs_account_ids - @status.reblogs.pluck(:account_id) + @status.reblogs.pluck(:account_id) if distributable? || unsafe? end + # Beware: Favourites can be created without the author having had access to the status def favourites_account_ids - @status.favourites.pluck(:account_id) + @status.favourites.pluck(:account_id) if distributable? || unsafe? end + # Beware: Replies can be created without the author having had access to the status def replies_account_ids - @status.replies.pluck(:account_id) + @status.replies.pluck(:account_id) if distributable? || unsafe? end def followers_inboxes - if @status.in_reply_to_local_account? && @status.distributable? + if @status.in_reply_to_local_account? && distributable? @status.account.followers.or(@status.thread.account.followers).inboxes + elsif @status.direct_visibility? || @status.limited_visibility? + [] else @status.account.followers.inboxes end @@ -76,4 +85,12 @@ class StatusReachFinder [] end end + + def distributable? + @status.public_visibility? || @status.unlisted_visibility? + end + + def unsafe? + @options[:unsafe] + end end diff --git a/app/models/poll.rb b/app/models/poll.rb index d2a17277b..71b5e191f 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -26,6 +26,7 @@ class Poll < ApplicationRecord belongs_to :status has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all + has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account has_many :notifications, as: :activity, dependent: :destroy diff --git a/app/models/status.rb b/app/models/status.rb index 749a23718..3358d6891 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ # in_reply_to_account_id :bigint(8) # poll_id :bigint(8) # deleted_at :datetime +# edited_at :datetime # class Status < ApplicationRecord @@ -56,6 +57,8 @@ class Status < ApplicationRecord belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true + has_many :edits, class_name: 'StatusEdit', inverse_of: :status, dependent: :destroy + has_many :favourites, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy @@ -209,6 +212,10 @@ class Status < ApplicationRecord public_visibility? || unlisted_visibility? end + def edited? + edited_at.present? + end + alias sign? distributable? def with_media? diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb new file mode 100644 index 000000000..a89df86c5 --- /dev/null +++ b/app/models/status_edit.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_edits +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) not null +# account_id :bigint(8) +# text :text default(""), not null +# spoiler_text :text default(""), not null +# media_attachments_changed :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class StatusEdit < ApplicationRecord + belongs_to :status + belongs_to :account, optional: true + + default_scope { order(id: :asc) } + + delegate :local?, to: :status +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 7c52b634d..12dabc65a 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -11,6 +11,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer attribute :content attribute :content_map, if: :language? + attribute :updated, if: :edited? has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag @@ -65,6 +66,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.language.present? end + delegate :edited?, to: :object + def in_reply_to return unless object.reply? && !object.thread.nil? @@ -79,6 +82,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.created_at.iso8601 end + def updated + object.edited_at.iso8601 + end + def url ActivityPub::TagManager.instance.url_for(object) end diff --git a/app/serializers/rest/status_edit_serializer.rb b/app/serializers/rest/status_edit_serializer.rb new file mode 100644 index 000000000..b123b4e09 --- /dev/null +++ b/app/serializers/rest/status_edit_serializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class REST::StatusEditSerializer < ActiveModel::Serializer + attributes :text, :spoiler_text, :media_attachments_changed, + :created_at +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index e84f3bd61..aef51e0f7 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility, :language, :uri, :url, :replies_count, :reblogs_count, - :favourites_count + :favourites_count, :edited_at attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? diff --git a/app/serializers/rest/status_source_serializer.rb b/app/serializers/rest/status_source_serializer.rb new file mode 100644 index 000000000..cd3c74084 --- /dev/null +++ b/app/serializers/rest/status_source_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::StatusSourceSerializer < ActiveModel::Serializer + attributes :id, :text, :spoiler_text + + def id + object.id.to_s + end +end diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index 1c79ecf11..1829e791c 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -8,6 +8,6 @@ class ActivityPub::FetchRemotePollService < BaseService return unless supported_context?(json) - ActivityPub::ProcessPollService.new.call(poll, json) + ActivityPub::ProcessStatusUpdateService.new.call(poll.status, json) end end diff --git a/app/services/activitypub/process_poll_service.rb b/app/services/activitypub/process_poll_service.rb deleted file mode 100644 index d83e614d8..000000000 --- a/app/services/activitypub/process_poll_service.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ProcessPollService < BaseService - include JsonLdHelper - - def call(poll, json) - @json = json - - return unless expected_type? - - previous_expires_at = poll.expires_at - - expires_at = begin - if @json['closed'].is_a?(String) - @json['closed'] - elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) - Time.now.utc - else - @json['endTime'] - end - end - - items = begin - if @json['anyOf'].is_a?(Array) - @json['anyOf'] - else - @json['oneOf'] - end - end - - voters_count = @json['votersCount'] - - latest_options = items.filter_map { |item| item['name'].presence || item['content'] } - - # If for some reasons the options were changed, it invalidates all previous - # votes, so we need to remove them - poll.votes.delete_all if latest_options != poll.options - - begin - poll.update!( - last_fetched_at: Time.now.utc, - expires_at: expires_at, - options: latest_options, - cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }, - voters_count: voters_count - ) - rescue ActiveRecord::StaleObjectError - poll.reload - retry - end - - # If the poll had no expiration date set but now has, and people have voted, - # schedule a notification. - if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists? - PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) - end - end - - private - - def expected_type? - equals_or_includes_any?(@json['type'], %w(Question)) - end -end diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb new file mode 100644 index 000000000..e3e9b9d6a --- /dev/null +++ b/app/services/activitypub/process_status_update_service.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessStatusUpdateService < BaseService + include JsonLdHelper + + def call(status, json) + @json = json + @status_parser = ActivityPub::Parser::StatusParser.new(@json) + @uri = @status_parser.uri + @status = status + @account = status.account + @media_attachments_changed = false + + # Only native types can be updated at the moment + return if !expected_type? || already_updated_more_recently? + + # Only allow processing one create/update per status at a time + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + Status.transaction do + create_previous_edit! + update_media_attachments! + update_poll! + update_immediate_attributes! + update_metadata! + create_edit! + end + + queue_poll_notifications! + reset_preview_card! + broadcast_updates! + else + raise Mastodon::RaceConditionError + end + end + end + + private + + def update_media_attachments! + previous_media_attachments = @status.media_attachments.to_a + next_media_attachments = [] + + as_array(@json['attachment']).each do |attachment| + media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) + + next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4 + + begin + media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url } + media_attachment ||= MediaAttachment.new(account: @account, remote_url: media_attachment_parser.remote_url) + + # If a previously existing media attachment was significantly updated, mark + # media attachments as changed even if none were added or removed + if media_attachment_parser.significantly_changes?(media_attachment) + @media_attachments_changed = true + end + + media_attachment.description = media_attachment_parser.description + media_attachment.focus = media_attachment_parser.focus + media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url + media_attachment.blurhash = media_attachment_parser.blurhash + media_attachment.save! + + next_media_attachments << media_attachment + + next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download? + + RedownloadMediaWorker.perform_async(media_attachment.id) if media_attachment.remote_url_previously_changed? || media_attachment.thumbnail_remote_url_previously_changed? + rescue Addressable::URI::InvalidURIError => e + Rails.logger.debug "Invalid URL in attachment: #{e}" + end + end + + removed_media_attachments = previous_media_attachments - next_media_attachments + added_media_attachments = next_media_attachments - previous_media_attachments + + MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil) + MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id) + + @media_attachments_changed = true if removed_media_attachments.positive? || added_media_attachments.positive? + end + + def update_poll! + previous_poll = @status.preloadable_poll + @previous_expires_at = previous_poll&.expires_at + poll_parser = ActivityPub::Parser::PollParser.new(@json) + + if poll_parser.valid? + poll = previous_poll || @account.polls.new(status: @status) + + # If for some reasons the options were changed, it invalidates all previous + # votes, so we need to remove them + if poll_parser.significantly_changes?(poll) + @media_attachments_changed = true + poll.votes.delete_all unless poll.new_record? + end + + poll.last_fetched_at = Time.now.utc + poll.options = poll_parser.options + poll.multiple = poll_parser.multiple + poll.expires_at = poll_parser.expires_at + poll.voters_count = poll_parser.voters_count + poll.cached_tallies = poll_parser.cached_tallies + poll.save! + + @status.poll_id = poll.id + elsif previous_poll.present? + previous_poll.destroy! + @media_attachments_changed = true + @status.poll_id = nil + end + end + + def update_immediate_attributes! + @status.text = @status_parser.text || '' + @status.spoiler_text = @status_parser.spoiler_text || '' + @status.sensitive = @account.sensitized? || @status_parser.sensitive || false + @status.language = @status_parser.language || detected_language + @status.edited_at = @status_parser.edited_at || Time.now.utc + + @status.save! + end + + def update_metadata! + @raw_tags = [] + @raw_mentions = [] + @raw_emojis = [] + + as_array(@json['tag']).each do |tag| + if equals_or_includes?(tag['type'], 'Hashtag') + @raw_tags << tag['name'] + elsif equals_or_includes?(tag['type'], 'Mention') + @raw_mentions << tag['href'] + elsif equals_or_includes?(tag['type'], 'Emoji') + @raw_emojis << tag + end + end + + update_tags! + update_mentions! + update_emojis! + end + + def update_tags! + @status.tags = Tag.find_or_create_by_names(@raw_tags) + end + + def update_mentions! + previous_mentions = @status.active_mentions.includes(:account).to_a + current_mentions = [] + + @raw_mentions.each do |href| + next if href.blank? + + account = ActivityPub::TagManager.instance.uri_to_resource(href, Account) + account ||= ActivityPub::FetchRemoteAccountService.new.call(href) + + next if account.nil? + + mention = previous_mentions.find { |x| x.account_id == account.id } + mention ||= account.mentions.new(status: @status) + + current_mentions << mention + end + + current_mentions.each do |mention| + mention.save if mention.new_record? + end + + # If previous mentions are no longer contained in the text, convert them + # to silent mentions, since withdrawing access from someone who already + # received a notification might be more confusing + removed_mentions = previous_mentions - current_mentions + + Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? + end + + def update_emojis! + return if skip_download? + + @raw_emojis.each do |raw_emoji| + custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(raw_emoji) + + next if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? + + emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain) + + next unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at) + + begin + emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri) + emoji.image_remote_url = custom_emoji_parser.image_remote_url + emoji.save + rescue Seahorse::Client::NetworkingError => e + Rails.logger.warn "Error storing emoji: #{e}" + end + end + end + + def expected_type? + equals_or_includes_any?(@json['type'], %w(Note Question)) + end + + def lock_options + { redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds } + end + + def detected_language + LanguageDetector.instance.detect(@status_parser.text, @account) + end + + def create_previous_edit! + # We only need to create a previous edit when no previous edits exist, e.g. + # when the status has never been edited. For other cases, we always create + # an edit, so the step can be skipped + + return if @status.edits.any? + + @status.edits.create( + text: @status.text, + spoiler_text: @status.spoiler_text, + media_attachments_changed: false, + account_id: @account.id, + created_at: @status.created_at + ) + end + + def create_edit! + return unless @status.text_previously_changed? || @status.spoiler_text_previously_changed? || @media_attachments_changed + + @status_edit = @status.edits.create( + text: @status.text, + spoiler_text: @status.spoiler_text, + media_attachments_changed: @media_attachments_changed, + account_id: @account.id, + created_at: @status.edited_at + ) + end + + def skip_download? + return @skip_download if defined?(@skip_download) + + @skip_download ||= DomainBlock.reject_media?(@account.domain) + end + + def unsupported_media_type?(mime_type) + mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) + end + + def already_updated_more_recently? + @status.edited_at.present? && @status_parser.edited_at.present? && @status.edited_at > @status_parser.edited_at + end + + def reset_preview_card! + @status.preview_cards.clear if @status.text_previously_changed? || @status.spoiler_text.present? + LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id) if @status.spoiler_text.blank? + end + + def broadcast_updates! + ::DistributionWorker.perform_async(@status.id, update: true) + end + + def queue_poll_notifications! + poll = @status.preloadable_poll + + # If the poll had no expiration date set but now has, or now has a sooner + # expiration date, and people have voted, schedule a notification + + return unless poll.present? && poll.expires_at.present? && poll.votes.exists? + + PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at + PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) + end +end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index b72bb82d3..f62f78a79 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -3,107 +3,126 @@ class FanOutOnWriteService < BaseService # Push a status into home and mentions feeds # @param [Status] status - def call(status) - raise Mastodon::RaceConditionError if status.visibility.nil? - - deliver_to_self(status) if status.account.local? - - if status.direct_visibility? - deliver_to_mentioned_followers(status) - deliver_to_own_conversation(status) - elsif status.limited_visibility? - deliver_to_mentioned_followers(status) - else - deliver_to_followers(status) - deliver_to_lists(status) - end + # @param [Hash] options + # @option options [Boolean] update + # @option options [Array] silenced_account_ids + def call(status, options = {}) + @status = status + @account = status.account + @options = options + + check_race_condition! + + fan_out_to_local_recipients! + fan_out_to_public_streams! if broadcastable? + end - return if status.account.silenced? || !status.public_visibility? || status.reblog? + private - render_anonymous_payload(status) + def check_race_condition! + # I don't know why but at some point we had an issue where + # this service was being executed with status objects + # that had a null visibility - which should not be possible + # since the column in the database is not nullable. + # + # This check re-queues the service to be run at a later time + # with the full object, if something like it occurs - deliver_to_hashtags(status) + raise Mastodon::RaceConditionError if @status.visibility.nil? + end - return if status.reply? && status.in_reply_to_account_id != status.account_id + def fan_out_to_local_recipients! + deliver_to_self! + notify_mentioned_accounts! - deliver_to_public(status) - deliver_to_media(status) if status.media_attachments.any? + case @status.visibility.to_sym + when :public, :unlisted, :private + deliver_to_all_followers! + deliver_to_lists! + when :limited + deliver_to_mentioned_followers! + else + deliver_to_mentioned_followers! + deliver_to_conversation! + end end - private + def fan_out_to_public_streams! + broadcast_to_hashtag_streams! + broadcast_to_public_streams! + end - def deliver_to_self(status) - Rails.logger.debug "Delivering status #{status.id} to author" - FeedManager.instance.push_to_home(status.account, status) + def deliver_to_self! + FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local? end - def deliver_to_followers(status) - Rails.logger.debug "Delivering status #{status.id} to followers" + def notify_mentioned_accounts! + @status.active_mentions.where.not(id: @options[:silenced_account_ids] || []).joins(:account).merge(Account.local).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| + LocalNotificationWorker.push_bulk(mentions) do |mention| + [mention.account_id, mention.id, 'Mention', :mention] + end + end + end - status.account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers| + def deliver_to_all_followers! + @account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers| FeedInsertWorker.push_bulk(followers) do |follower| - [status.id, follower.id, :home] + [@status.id, follower.id, :home, update: update?] end end end - def deliver_to_lists(status) - Rails.logger.debug "Delivering status #{status.id} to lists" - - status.account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| + def deliver_to_lists! + @account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| FeedInsertWorker.push_bulk(lists) do |list| - [status.id, list.id, :list] + [@status.id, list.id, :list, update: update?] end end end - def deliver_to_mentioned_followers(status) - Rails.logger.debug "Delivering status #{status.id} to limited followers" - - status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| + def deliver_to_mentioned_followers! + @status.mentions.joins(:account).merge(@account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| FeedInsertWorker.push_bulk(mentions) do |mention| - [status.id, mention.account_id, :home] + [@status.id, mention.account_id, :home, update: update?] end end end - def render_anonymous_payload(status) - @payload = InlineRenderer.render(status, nil, :status) - @payload = Oj.dump(event: :update, payload: @payload) + def broadcast_to_hashtag_streams! + @status.tags.pluck(:name).each do |hashtag| + Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload) + Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local? + end end - def deliver_to_hashtags(status) - Rails.logger.debug "Delivering status #{status.id} to hashtags" + def broadcast_to_public_streams! + return if @status.reply? && @status.in_reply_to_account_id != @account.id + + Redis.current.publish('timeline:public', anonymous_payload) + Redis.current.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', anonymous_payload) - status.tags.pluck(:name).each do |hashtag| - Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) - Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if status.local? + if @status.media_attachments.any? + Redis.current.publish('timeline:public:media', anonymous_payload) + Redis.current.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', anonymous_payload) end end - def deliver_to_public(status) - Rails.logger.debug "Delivering status #{status.id} to public timeline" - - Redis.current.publish('timeline:public', @payload) - if status.local? - Redis.current.publish('timeline:public:local', @payload) - else - Redis.current.publish('timeline:public:remote', @payload) - end + def deliver_to_conversation! + AccountConversation.add_status(@account, @status) unless update? end - def deliver_to_media(status) - Rails.logger.debug "Delivering status #{status.id} to media timeline" + def anonymous_payload + @anonymous_payload ||= Oj.dump( + event: update? ? :'status.update' : :update, + payload: InlineRenderer.render(@status, nil, :status) + ) + end - Redis.current.publish('timeline:public:media', @payload) - if status.local? - Redis.current.publish('timeline:public:local:media', @payload) - else - Redis.current.publish('timeline:public:remote:media', @payload) - end + def update? + @is_update end - def deliver_to_own_conversation(status) - AccountConversation.add_status(status.account, status) + def broadcastable? + @status.public_visibility? && !@status.reblog? && !@account.silenced? end end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 73dbb1834..9d239fc65 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -8,12 +8,23 @@ class ProcessMentionsService < BaseService # remote users # @param [Status] status def call(status) - return unless status.local? + @status = status - @status = status - mentions = [] + return unless @status.local? - status.text = status.text.gsub(Account::MENTION_RE) do |match| + @previous_mentions = @status.active_mentions.includes(:account).to_a + @current_mentions = [] + + Status.transaction do + scan_text! + assign_mentions! + end + end + + private + + def scan_text! + @status.text = @status.text.gsub(Account::MENTION_RE) do |match| username, domain = Regexp.last_match(1).split('@') domain = begin @@ -26,49 +37,45 @@ class ProcessMentionsService < BaseService mentioned_account = Account.find_remote(username, domain) + # If the account cannot be found or isn't the right protocol, + # first try to resolve it if mention_undeliverable?(mentioned_account) begin - mentioned_account = resolve_account_service.call(Regexp.last_match(1)) + mentioned_account = ResolveAccountService.new.call(Regexp.last_match(1)) rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError mentioned_account = nil end end + # If after resolving it still isn't found or isn't the right + # protocol, then give up next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended? - mention = mentioned_account.mentions.new(status: status) - mentions << mention if mention.save + mention = @previous_mentions.find { |x| x.account_id == mentioned_account.id } + mention ||= mentioned_account.mentions.new(status: @status) + + @current_mentions << mention "@#{mentioned_account.acct}" end - status.save! - - mentions.each { |mention| create_notification(mention) } + @status.save! end - private - - def mention_undeliverable?(mentioned_account) - mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?) - end - - def create_notification(mention) - mentioned_account = mention.account - - if mentioned_account.local? - LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) - elsif mentioned_account.activitypub? - ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? }) + def assign_mentions! + @current_mentions.each do |mention| + mention.save if mention.new_record? end - end - def activitypub_json - return @activitypub_json if defined?(@activitypub_json) - @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) + # If previous mentions are no longer contained in the text, convert them + # to silent mentions, since withdrawing access from someone who already + # received a notification might be more confusing + removed_mentions = @previous_mentions - @current_mentions + + Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? end - def resolve_account_service - ResolveAccountService.new + def mention_undeliverable?(mentioned_account) + mentioned_account.nil? || (!mentioned_account.local? && !mentioned_account.activitypub?) end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 3535b503b..bec95bb1b 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -87,7 +87,7 @@ class RemoveStatusService < BaseService # the author and wouldn't normally receive the delete # notification - so here, we explicitly send it to them - status_reach_finder = StatusReachFinder.new(@status) + status_reach_finder = StatusReachFinder.new(@status, unsafe: true) ActivityPub::DeliveryWorker.push_bulk(status_reach_finder.inboxes) do |inbox_url| [signed_activity_json, @account.id, inbox_url] diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 09898ca49..17c108461 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -1,54 +1,32 @@ # frozen_string_literal: true -class ActivityPub::DistributionWorker - include Sidekiq::Worker - include Payloadable - - sidekiq_options queue: 'push' - +class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker + # Distribute a new status or an edit of a status to all the places + # where the status is supposed to go or where it was interacted with def perform(status_id) @status = Status.find(status_id) @account = @status.account - return if skip_distribution? - - ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] - end - - relay! if relayable? + distribute! rescue ActiveRecord::RecordNotFound true end - private - - def skip_distribution? - @status.direct_visibility? || @status.limited_visibility? - end - - def relayable? - @status.public_visibility? - end + protected def inboxes - # Deliver the status to all followers. - # If the status is a reply to another local status, also forward it to that - # status' authors' followers. - @inboxes ||= if @status.in_reply_to_local_account? && @status.distributable? - @account.followers.or(@status.thread.account.followers).inboxes - else - @account.followers.inboxes - end + @inboxes ||= StatusReachFinder.new(@status).inboxes end def payload - @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account)) + @payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account)) + end + + def activity + ActivityPub::ActivityPresenter.from_status(@status) end - def relay! - ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| - [payload, @account.id, inbox_url] - end + def options + { synchronize_followers: @status.private_visibility? } end end diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb index 41e61132f..ac5eda4af 100644 --- a/app/workers/activitypub/raw_distribution_worker.rb +++ b/app/workers/activitypub/raw_distribution_worker.rb @@ -2,22 +2,47 @@ class ActivityPub::RawDistributionWorker include Sidekiq::Worker + include Payloadable sidekiq_options queue: 'push' + # Base worker for when you want to queue up a bunch of deliveries of + # some payload. In this case, we have already generated JSON and + # we are going to distribute it to the account's followers minus + # the explicitly provided inboxes def perform(json, source_account_id, exclude_inboxes = []) - @account = Account.find(source_account_id) + @account = Account.find(source_account_id) + @json = json + @exclude_inboxes = exclude_inboxes - ActivityPub::DeliveryWorker.push_bulk(inboxes - exclude_inboxes) do |inbox_url| - [json, @account.id, inbox_url] - end + distribute! rescue ActiveRecord::RecordNotFound true end - private + protected + + def distribute! + return if inboxes.empty? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, source_account_id, inbox_url, options] + end + end + + def payload + @json + end + + def source_account_id + @account.id + end def inboxes - @inboxes ||= @account.followers.inboxes + @inboxes ||= @account.followers.inboxes - @exclude_inboxes + end + + def options + nil end end diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb deleted file mode 100644 index d4d0148ac..000000000 --- a/app/workers/activitypub/reply_distribution_worker.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -# Obsolete but kept around to make sure existing jobs do not fail after upgrade. -# Should be removed in a subsequent release. - -class ActivityPub::ReplyDistributionWorker - include Sidekiq::Worker - include Payloadable - - sidekiq_options queue: 'push' - - def perform(status_id) - @status = Status.find(status_id) - @account = @status.thread&.account - - return unless @account.present? && @status.distributable? - - ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @status.account_id, inbox_url] - end - rescue ActiveRecord::RecordNotFound - true - end - - private - - def inboxes - @inboxes ||= @account.followers.inboxes - end - - def payload - @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) - end -end diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb index 3a207f071..81fde63b8 100644 --- a/app/workers/activitypub/update_distribution_worker.rb +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -1,33 +1,24 @@ # frozen_string_literal: true -class ActivityPub::UpdateDistributionWorker - include Sidekiq::Worker - include Payloadable - - sidekiq_options queue: 'push' - +class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker + # Distribute an profile update to servers that might have a copy + # of the account in question def perform(account_id, options = {}) @options = options.with_indifferent_access @account = Account.find(account_id) - ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [signed_payload, @account.id, inbox_url] - end - - ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| - [signed_payload, @account.id, inbox_url] - end + distribute! rescue ActiveRecord::RecordNotFound true end - private + protected def inboxes - @inboxes ||= @account.followers.inboxes + @inboxes ||= AccountReachFinder.new(@account).inboxes end - def signed_payload - @signed_payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with])) + def payload + @payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with])) end end diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb index e85cd7e95..770325ccf 100644 --- a/app/workers/distribution_worker.rb +++ b/app/workers/distribution_worker.rb @@ -3,10 +3,10 @@ class DistributionWorker include Sidekiq::Worker - def perform(status_id) + def perform(status_id, options = {}) RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}", autorelease: 5.minutes.seconds) do |lock| if lock.acquired? - FanOutOnWriteService.new.call(Status.find(status_id)) + FanOutOnWriteService.new.call(Status.find(status_id), **options.symbolize_keys) else raise Mastodon::RaceConditionError end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index b70c7e389..0122be95d 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -3,9 +3,10 @@ class FeedInsertWorker include Sidekiq::Worker - def perform(status_id, id, type = :home) - @type = type.to_sym - @status = Status.find(status_id) + def perform(status_id, id, type = :home, options = {}) + @type = type.to_sym + @status = Status.find(status_id) + @options = options.symbolize_keys case @type when :home @@ -23,10 +24,12 @@ class FeedInsertWorker private def check_and_insert - return if feed_filtered? - - perform_push - perform_notify if notify? + if feed_filtered? + perform_unpush if update? + else + perform_push + perform_notify if notify? + end end def feed_filtered? @@ -47,13 +50,26 @@ class FeedInsertWorker def perform_push case @type when :home - FeedManager.instance.push_to_home(@follower, @status) + FeedManager.instance.push_to_home(@follower, @status, update: update?) + when :list + FeedManager.instance.push_to_list(@list, @status, update: update?) + end + end + + def perform_unpush + case @type + when :home + FeedManager.instance.unpush_from_home(@follower, @status, update: true) when :list - FeedManager.instance.push_to_list(@list, @status) + FeedManager.instance.unpush_from_list(@list, @status, update: true) end end def perform_notify NotifyService.new.call(@follower, :status, @status) end + + def update? + @options[:update] + end end diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb index 6b08ca6fc..a22e2834d 100644 --- a/app/workers/local_notification_worker.rb +++ b/app/workers/local_notification_worker.rb @@ -12,6 +12,8 @@ class LocalNotificationWorker activity = activity_class_name.constantize.find(activity_id) end + return if Notification.where(account: receiver, activity: activity).any? + NotifyService.new.call(receiver, type || activity_class_name.underscore, activity) rescue ActiveRecord::RecordNotFound true diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb index f0191d479..7613ed5f1 100644 --- a/app/workers/poll_expiration_notify_worker.rb +++ b/app/workers/poll_expiration_notify_worker.rb @@ -6,19 +6,44 @@ class PollExpirationNotifyWorker sidekiq_options lock: :until_executed def perform(poll_id) - poll = Poll.find(poll_id) + @poll = Poll.find(poll_id) - # Notify poll owner and remote voters - if poll.local? - ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id) - NotifyService.new.call(poll.account, :poll, poll) - end + return if does_not_expire? + requeue! && return if not_due_yet? - # Notify local voters - poll.votes.includes(:account).group(:account_id).select(:account_id).map(&:account).select(&:local?).each do |account| - NotifyService.new.call(account, :poll, poll) - end + notify_remote_voters_and_owner! if @poll.local? + notify_local_voters! rescue ActiveRecord::RecordNotFound true end + + def self.remove_from_scheduled(poll_id) + queue = Sidekiq::ScheduledSet.new + queue.select { |scheduled| scheduled.klass == name && scheduled.args[0] == poll_id }.map(&:delete) + end + + private + + def does_not_expire? + @poll.expires_at.nil? + end + + def not_due_yet? + @poll.expires_at.present? && !@poll.expired? + end + + def requeue! + PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id) + end + + def notify_remote_voters_and_owner! + ActivityPub::DistributePollUpdateWorker.perform_async(@poll.status.id) + NotifyService.new.call(@poll.account, :poll, @poll) + end + + def notify_local_voters! + @poll.voters.merge(Account.local).find_each do |account| + NotifyService.new.call(account, :poll, @poll) + end + end end diff --git a/app/workers/push_update_worker.rb b/app/workers/push_update_worker.rb index d76d73d96..ae444cfde 100644 --- a/app/workers/push_update_worker.rb +++ b/app/workers/push_update_worker.rb @@ -2,15 +2,38 @@ class PushUpdateWorker include Sidekiq::Worker + include Redisable - def perform(account_id, status_id, timeline_id = nil) - account = Account.find(account_id) - status = Status.find(status_id) - message = InlineRenderer.render(status, account, :status) - timeline_id = "timeline:#{account.id}" if timeline_id.nil? + def perform(account_id, status_id, timeline_id = nil, options = {}) + @account = Account.find(account_id) + @status = Status.find(status_id) + @timeline_id = timeline_id || "timeline:#{account.id}" + @options = options.symbolize_keys - Redis.current.publish(timeline_id, Oj.dump(event: :update, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) + publish! rescue ActiveRecord::RecordNotFound true end + + private + + def payload + InlineRenderer.render(@status, @account, :status) + end + + def message + Oj.dump( + event: update? ? :'status.update' : :update, + payload: payload, + queued_at: (Time.now.to_f * 1000.0).to_i + ) + end + + def publish! + redis.publish(@timeline_id, message) + end + + def update? + @options[:update] + end end diff --git a/config/routes.rb b/config/routes.rb index 41ba45379..121587819 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -350,6 +350,9 @@ Rails.application.routes.draw do resource :pin, only: :create post :unpin, to: 'pins#destroy' + + resource :history, only: :show + resource :source, only: :show end member do diff --git a/db/migrate/20210904215403_add_edited_at_to_statuses.rb b/db/migrate/20210904215403_add_edited_at_to_statuses.rb new file mode 100644 index 000000000..216ad8e13 --- /dev/null +++ b/db/migrate/20210904215403_add_edited_at_to_statuses.rb @@ -0,0 +1,5 @@ +class AddEditedAtToStatuses < ActiveRecord::Migration[6.1] + def change + add_column :statuses, :edited_at, :datetime + end +end diff --git a/db/migrate/20210908220918_create_status_edits.rb b/db/migrate/20210908220918_create_status_edits.rb new file mode 100644 index 000000000..6c90149d0 --- /dev/null +++ b/db/migrate/20210908220918_create_status_edits.rb @@ -0,0 +1,13 @@ +class CreateStatusEdits < ActiveRecord::Migration[6.1] + def change + create_table :status_edits do |t| + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :account, null: true, foreign_key: { on_delete: :nullify } + t.text :text, null: false, default: '' + t.text :spoiler_text, null: false, default: '' + t.boolean :media_attachments_changed, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ed615a1ee..4e0f76dcd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -816,6 +816,18 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do t.index ["var"], name: "index_site_uploads_on_var", unique: true end + create_table "status_edits", force: :cascade do |t| + t.bigint "status_id", null: false + t.bigint "account_id" + t.text "text", default: "", null: false + t.text "spoiler_text", default: "", null: false + t.boolean "media_attachments_changed", default: false, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_status_edits_on_account_id" + t.index ["status_id"], name: "index_status_edits_on_status_id" + end + create_table "status_pins", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "status_id", null: false @@ -854,6 +866,7 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do t.bigint "in_reply_to_account_id" t.bigint "poll_id" t.datetime "deleted_at" + t.datetime "edited_at" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" @@ -1081,6 +1094,8 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade + add_foreign_key "status_edits", "accounts", on_delete: :nullify + add_foreign_key "status_edits", "statuses", on_delete: :cascade add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade add_foreign_key "status_pins", "statuses", on_delete: :cascade add_foreign_key "status_stats", "statuses", on_delete: :cascade diff --git a/spec/controllers/api/v1/statuses/histories_controller_spec.rb b/spec/controllers/api/v1/statuses/histories_controller_spec.rb new file mode 100644 index 000000000..8d9d6a359 --- /dev/null +++ b/spec/controllers/api/v1/statuses/histories_controller_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::HistoriesController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #show' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + get :show, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end + end +end diff --git a/spec/controllers/api/v1/statuses/sources_controller_spec.rb b/spec/controllers/api/v1/statuses/sources_controller_spec.rb new file mode 100644 index 000000000..293c90ec9 --- /dev/null +++ b/spec/controllers/api/v1/statuses/sources_controller_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::SourcesController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #show' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + get :show, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end + end +end diff --git a/spec/fabricators/preview_card_fabricator.rb b/spec/fabricators/preview_card_fabricator.rb new file mode 100644 index 000000000..f119c117d --- /dev/null +++ b/spec/fabricators/preview_card_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:preview_card) do + url { Faker::Internet.url } + title { Faker::Lorem.sentence } + description { Faker::Lorem.paragraph } + type 'link' +end diff --git a/spec/fabricators/status_edit_fabricator.rb b/spec/fabricators/status_edit_fabricator.rb new file mode 100644 index 000000000..21b793747 --- /dev/null +++ b/spec/fabricators/status_edit_fabricator.rb @@ -0,0 +1,7 @@ +Fabricator(:status_edit) do + status nil + account nil + text "MyText" + spoiler_text "MyText" + media_attachments_changed false +end \ No newline at end of file diff --git a/spec/lib/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb new file mode 100644 index 000000000..f0c22b165 --- /dev/null +++ b/spec/lib/status_reach_finder_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe StatusReachFinder do + describe '#inboxes' do + context 'for a local status' do + let(:parent_status) { nil } + let(:visibility) { :public } + let(:alice) { Fabricate(:account, username: 'alice') } + let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) } + + subject { described_class.new(status) } + + context 'when it contains mentions of remote accounts' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + + before do + status.mentions.create!(account: bob) + end + + it 'includes the inbox of the mentioned account' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + end + + context 'when it has been reblogged by a remote account' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + + before do + bob.statuses.create!(reblog: status) + end + + it 'includes the inbox of the reblogger' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + + context 'when status is not public' do + let(:visibility) { :private } + + it 'does not include the inbox of the reblogger' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + end + end + end + + context 'when it has been favourited by a remote account' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + + before do + bob.favourites.create!(status: status) + end + + it 'includes the inbox of the favouriter' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + + context 'when status is not public' do + let(:visibility) { :private } + + it 'does not include the inbox of the favouriter' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + end + end + end + + context 'when it has been replied to by a remote account' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + + before do + bob.statuses.create!(thread: status, text: 'Hoge') + end + + context do + it 'includes the inbox of the replier' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + end + + context 'when status is not public' do + let(:visibility) { :private } + + it 'does not include the inbox of the replier' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + end + end + end + + context 'when it is a reply to a remote account' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + let(:parent_status) { Fabricate(:status, account: bob) } + + context do + it 'includes the inbox of the replied-to account' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + end + + context 'when status is not public and replied-to account is not mentioned' do + let(:visibility) { :private } + + it 'does not include the inbox of the replied-to account' do + expect(subject.inboxes).to_not include 'https://foo.bar/inbox' + end + end + end + end + end +end diff --git a/spec/models/status_edit_spec.rb b/spec/models/status_edit_spec.rb new file mode 100644 index 000000000..2ecafef73 --- /dev/null +++ b/spec/models/status_edit_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe StatusEdit, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index ceba5f210..94574aa7f 100644 --- a/spec/services/activitypub/fetch_remote_status_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -67,7 +67,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do expect(status).to_not be_nil expect(status.url).to eq "https://#{valid_domain}/watch?v=12345" - expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remix https://#{valid_domain}/watch?v=12345" + expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remixhttps://#{valid_domain}/watch?v=12345" end end @@ -100,7 +100,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do expect(status).to_not be_nil expect(status.url).to eq "https://#{valid_domain}/watch?v=12345" - expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remix https://#{valid_domain}/watch?v=12345" + expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remixhttps://#{valid_domain}/watch?v=12345" end end @@ -120,7 +120,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do expect(status).to_not be_nil expect(status.url).to eq "https://#{valid_domain}/@foo/1234" - expect(strip_tags(status.text)).to eq "Let's change the world https://#{valid_domain}/@foo/1234" + expect(strip_tags(status.text)).to eq "Let's change the worldhttps://#{valid_domain}/@foo/1234" end end diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb index 538dc2592..4ce110e45 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -1,37 +1,112 @@ require 'rails_helper' RSpec.describe FanOutOnWriteService, type: :service do - let(:author) { Fabricate(:account, username: 'tom') } - let(:status) { Fabricate(:status, text: 'Hello @alice #test', account: author) } - let(:alice) { Fabricate(:user, account: Fabricate(:account, username: 'alice')).account } - let(:follower) { Fabricate(:account, username: 'bob') } + let(:last_active_at) { Time.now.utc } - subject { FanOutOnWriteService.new } + let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'alice')).account } + let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'bob')).account } + let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'tom')).account } + + subject { described_class.new } + + let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') } before do - alice - follower.follow!(author) + bob.follow!(alice) + tom.follow!(alice) ProcessMentionsService.new.call(status) ProcessHashtagsService.new.call(status) + allow(Redis.current).to receive(:publish) + subject.call(status) end - it 'delivers status to home timeline' do - expect(HomeFeed.new(author).get(10).map(&:id)).to include status.id + def home_feed_of(account) + HomeFeed.new(account).get(10).map(&:id) + end + + context 'when status is public' do + let(:visibility) { 'public' } + + it 'is added to the home feed of its author' do + expect(home_feed_of(alice)).to include status.id + end + + it 'is added to the home feed of a follower' do + expect(home_feed_of(bob)).to include status.id + expect(home_feed_of(tom)).to include status.id + end + + it 'is broadcast to the hashtag stream' do + expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge:local', anything) + end + + it 'is broadcast to the public stream' do + expect(Redis.current).to have_received(:publish).with('timeline:public', anything) + expect(Redis.current).to have_received(:publish).with('timeline:public:local', anything) + end end - it 'delivers status to local followers' do - pending 'some sort of problem in test environment causes this to sometimes fail' - expect(HomeFeed.new(follower).get(10).map(&:id)).to include status.id + context 'when status is limited' do + let(:visibility) { 'limited' } + + it 'is added to the home feed of its author' do + expect(home_feed_of(alice)).to include status.id + end + + it 'is added to the home feed of the mentioned follower' do + expect(home_feed_of(bob)).to include status.id + end + + it 'is not added to the home feed of the other follower' do + expect(home_feed_of(tom)).to_not include status.id + end + + it 'is not broadcast publicly' do + expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything) + end end - it 'delivers status to hashtag' do - expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id + context 'when status is private' do + let(:visibility) { 'private' } + + it 'is added to the home feed of its author' do + expect(home_feed_of(alice)).to include status.id + end + + it 'is added to the home feed of a follower' do + expect(home_feed_of(bob)).to include status.id + expect(home_feed_of(tom)).to include status.id + end + + it 'is not broadcast publicly' do + expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything) + end end - it 'delivers status to public timeline' do - expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id + context 'when status is direct' do + let(:visibility) { 'direct' } + + it 'is added to the home feed of its author' do + expect(home_feed_of(alice)).to include status.id + end + + it 'is added to the home feed of the mentioned follower' do + expect(home_feed_of(bob)).to include status.id + end + + it 'is not added to the home feed of the other follower' do + expect(home_feed_of(tom)).to_not include status.id + end + + it 'is not broadcast publicly' do + expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything) + end end end diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index d74e8dc62..89b265e9a 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -9,75 +9,55 @@ RSpec.describe ProcessMentionsService, type: :service do context 'ActivityPub' do context do - let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do - stub_request(:post, remote_user.inbox_url) subject.call(status) end it 'creates a mention' do expect(remote_user.mentions.where(status: status).count).to eq 1 end - - it 'sends activity to the inbox' do - expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once - end end context 'with an IDN domain' do - let(:remote_user) { Fabricate(:account, username: 'sneak', protocol: :activitypub, domain: 'xn--hresiar-mxa.ch', inbox_url: 'http://example.com/inbox') } - let(:status) { Fabricate(:status, account: account, text: "Hello @sneak@hæresiar.ch") } + let!(:remote_user) { Fabricate(:account, username: 'sneak', protocol: :activitypub, domain: 'xn--hresiar-mxa.ch', inbox_url: 'http://example.com/inbox') } + let!(:status) { Fabricate(:status, account: account, text: "Hello @sneak@hæresiar.ch") } before do - stub_request(:post, remote_user.inbox_url) subject.call(status) end it 'creates a mention' do expect(remote_user.mentions.where(status: status).count).to eq 1 end - - it 'sends activity to the inbox' do - expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once - end end context 'with an IDN TLD' do - let(:remote_user) { Fabricate(:account, username: 'foo', protocol: :activitypub, domain: 'xn--y9a3aq.xn--y9a3aq', inbox_url: 'http://example.com/inbox') } - let(:status) { Fabricate(:status, account: account, text: "Hello @foo@հայ.հայ") } + let!(:remote_user) { Fabricate(:account, username: 'foo', protocol: :activitypub, domain: 'xn--y9a3aq.xn--y9a3aq', inbox_url: 'http://example.com/inbox') } + let!(:status) { Fabricate(:status, account: account, text: "Hello @foo@հայ.հայ") } before do - stub_request(:post, remote_user.inbox_url) subject.call(status) end it 'creates a mention' do expect(remote_user.mentions.where(status: status).count).to eq 1 end - - it 'sends activity to the inbox' do - expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once - end end end context 'Temporarily-unreachable ActivityPub user' do - let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) } + let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) } before do stub_request(:get, "https://example.com/.well-known/host-meta").to_return(status: 404) stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:remote_user@example.com").to_return(status: 500) - stub_request(:post, remote_user.inbox_url) subject.call(status) end it 'creates a mention' do expect(remote_user.mentions.where(status: status).count).to eq 1 end - - it 'sends activity to the inbox' do - expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once - end end end diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb index 368ca025a..c017b4da1 100644 --- a/spec/workers/activitypub/distribution_worker_spec.rb +++ b/spec/workers/activitypub/distribution_worker_spec.rb @@ -35,13 +35,16 @@ describe ActivityPub::DistributionWorker do end context 'with direct status' do + let(:mentioned_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/inbox')} + before do status.update(visibility: :direct) + status.mentions.create!(account: mentioned_account) end - it 'does nothing' do + it 'delivers to mentioned accounts' do subject.perform(status.id) - expect(ActivityPub::DeliveryWorker).to_not have_received(:push_bulk) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['https://foo.bar/inbox']) end end end diff --git a/spec/workers/feed_insert_worker_spec.rb b/spec/workers/feed_insert_worker_spec.rb index 3509f1f50..fb34970fc 100644 --- a/spec/workers/feed_insert_worker_spec.rb +++ b/spec/workers/feed_insert_worker_spec.rb @@ -45,7 +45,7 @@ describe FeedInsertWorker do result = subject.perform(status.id, follower.id) expect(result).to be_nil - expect(instance).to have_received(:push_to_home).with(follower, status) + expect(instance).to have_received(:push_to_home).with(follower, status, update: nil) end end end -- cgit From e38fc319dc6897ca867a509b0c7a5878d34d0f00 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Jan 2022 00:46:42 +0100 Subject: Refactor and improve tests (#17386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Change account and user fabricators to simplify and improve tests - `Fabricate(:account)` implicitly fabricates an associated `user` if no `domain` attribute is given (an account with `domain: nil` is considered a local account, but no user record was created), unless `user: nil` is passed - `Fabricate(:account, user: Fabricate(:user))` should still be possible but is discouraged. * Fix and refactor tests - avoid passing unneeded attributes to `Fabricate(:user)` or `Fabricate(:account)` - avoid embedding `Fabricate(:user)` into a `Fabricate(:account)` or the other way around - prefer `Fabricate(:user, account_attributes: …)` to `Fabricate(:user, account: Fabricate(:account, …)` - also, some tests were using remote accounts with local user records, which is not representative of production code. --- spec/controllers/accounts_controller_spec.rb | 2 +- spec/controllers/admin/accounts_controller_spec.rb | 6 +++--- .../admin/change_email_controller_spec.rb | 12 +++++------- .../admin/confirmations_controller_spec.rb | 10 ++++------ spec/controllers/admin/resets_controller_spec.rb | 2 +- spec/controllers/api/base_controller_spec.rb | 2 +- .../api/v1/accounts/credentials_controller_spec.rb | 2 +- .../v1/accounts/follower_accounts_controller_spec.rb | 6 +++--- .../accounts/following_accounts_controller_spec.rb | 6 +++--- .../api/v1/accounts/lists_controller_spec.rb | 2 +- .../api/v1/accounts/notes_controller_spec.rb | 2 +- .../api/v1/accounts/pins_controller_spec.rb | 4 ++-- .../api/v1/accounts/relationships_controller_spec.rb | 6 +++--- .../api/v1/accounts/search_controller_spec.rb | 2 +- .../api/v1/accounts/statuses_controller_spec.rb | 2 +- spec/controllers/api/v1/accounts_controller_spec.rb | 20 ++++++++++---------- .../api/v1/admin/account_actions_controller_spec.rb | 4 ++-- .../api/v1/admin/accounts_controller_spec.rb | 4 ++-- .../api/v1/admin/reports_controller_spec.rb | 2 +- spec/controllers/api/v1/blocks_controller_spec.rb | 2 +- .../api/v1/conversations_controller_spec.rb | 4 ++-- .../api/v1/domain_blocks_controller_spec.rb | 2 +- .../api/v1/follow_requests_controller_spec.rb | 4 ++-- spec/controllers/api/v1/instances_controller_spec.rb | 2 +- .../api/v1/lists/accounts_controller_spec.rb | 2 +- spec/controllers/api/v1/lists_controller_spec.rb | 2 +- spec/controllers/api/v1/markers_controller_spec.rb | 2 +- spec/controllers/api/v1/media_controller_spec.rb | 2 +- spec/controllers/api/v1/mutes_controller_spec.rb | 2 +- .../api/v1/notifications_controller_spec.rb | 6 +++--- .../api/v1/polls/votes_controller_spec.rb | 2 +- spec/controllers/api/v1/polls_controller_spec.rb | 2 +- spec/controllers/api/v1/reports_controller_spec.rb | 2 +- .../api/v1/statuses/bookmarks_controller_spec.rb | 2 +- .../favourited_by_accounts_controller_spec.rb | 2 +- .../api/v1/statuses/favourites_controller_spec.rb | 2 +- .../api/v1/statuses/histories_controller_spec.rb | 2 +- .../api/v1/statuses/mutes_controller_spec.rb | 2 +- .../api/v1/statuses/pins_controller_spec.rb | 2 +- .../reblogged_by_accounts_controller_spec.rb | 2 +- .../api/v1/statuses/reblogs_controller_spec.rb | 2 +- .../api/v1/statuses/sources_controller_spec.rb | 2 +- spec/controllers/api/v1/statuses_controller_spec.rb | 2 +- .../api/v1/timelines/home_controller_spec.rb | 2 +- .../api/v1/timelines/list_controller_spec.rb | 4 ++-- .../api/v1/timelines/public_controller_spec.rb | 2 +- .../api/v1/timelines/tag_controller_spec.rb | 2 +- spec/controllers/api/v2/search_controller_spec.rb | 2 +- spec/controllers/application_controller_spec.rb | 6 +++--- .../auth/registrations_controller_spec.rb | 2 +- spec/controllers/auth/sessions_controller_spec.rb | 9 ++++++--- .../authorize_interactions_controller_spec.rb | 3 +-- .../concerns/account_controller_concern_spec.rb | 8 ++++---- .../controllers/concerns/accountable_concern_spec.rb | 8 ++++---- .../controllers/follower_accounts_controller_spec.rb | 2 +- .../following_accounts_controller_spec.rb | 2 +- spec/controllers/settings/deletes_controller_spec.rb | 4 ++-- .../settings/migrations_controller_spec.rb | 3 +-- .../controllers/settings/profiles_controller_spec.rb | 14 ++++++++------ spec/fabricators/account_fabricator.rb | 1 + spec/fabricators/user_fabricator.rb | 2 +- spec/mailers/admin_mailer_spec.rb | 8 ++++++-- spec/mailers/notification_mailer_spec.rb | 2 +- spec/models/account_spec.rb | 2 +- spec/models/admin/account_action_spec.rb | 4 ++-- spec/models/public_feed_spec.rb | 10 ++++------ spec/models/user_spec.rb | 6 +++--- spec/policies/account_moderation_note_policy_spec.rb | 4 ++-- spec/policies/account_policy_spec.rb | 4 ++-- spec/policies/backup_policy_spec.rb | 2 +- spec/policies/custom_emoji_policy_spec.rb | 2 +- spec/policies/domain_block_policy_spec.rb | 2 +- spec/policies/email_domain_block_policy_spec.rb | 2 +- spec/policies/instance_policy_spec.rb | 2 +- spec/policies/invite_policy_spec.rb | 2 +- spec/policies/relay_policy_spec.rb | 2 +- spec/policies/report_note_policy_spec.rb | 2 +- spec/policies/report_policy_spec.rb | 2 +- spec/policies/settings_policy_spec.rb | 2 +- spec/policies/tag_policy_spec.rb | 2 +- spec/policies/user_policy_spec.rb | 2 +- spec/services/authorize_follow_service_spec.rb | 4 ++-- spec/services/batched_remove_status_service_spec.rb | 2 +- spec/services/block_service_spec.rb | 4 ++-- spec/services/fan_out_on_write_service_spec.rb | 6 +++--- spec/services/favourite_service_spec.rb | 4 ++-- spec/services/follow_service_spec.rb | 20 ++++++++++---------- spec/services/reject_follow_service_spec.rb | 4 ++-- spec/services/remove_status_service_spec.rb | 2 +- spec/services/report_service_spec.rb | 2 +- spec/services/unblock_service_spec.rb | 4 ++-- spec/services/unfollow_service_spec.rb | 6 +++--- spec/services/update_account_service_spec.rb | 6 +++--- spec/workers/move_worker_spec.rb | 14 +++++++------- spec/workers/unfollow_follow_worker_spec.rb | 2 +- 95 files changed, 187 insertions(+), 185 deletions(-) (limited to 'spec/controllers/api/v1') diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index 7c5ba8754..73d124029 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe AccountsController, type: :controller do render_views - let(:account) { Fabricate(:user).account } + let(:account) { Fabricate(:account) } shared_examples 'cachable response' do it 'does not set cookies' do diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb index 3edbde03c..0f71d697c 100644 --- a/spec/controllers/admin/accounts_controller_spec.rb +++ b/spec/controllers/admin/accounts_controller_spec.rb @@ -61,7 +61,7 @@ RSpec.describe Admin::AccountsController, type: :controller do describe 'GET #show' do let(:current_user) { Fabricate(:user, admin: true) } - let(:account) { Fabricate(:account, username: 'bob') } + let(:account) { Fabricate(:account) } it 'returns http success' do get :show, params: { id: account.id } @@ -73,7 +73,7 @@ RSpec.describe Admin::AccountsController, type: :controller do subject { post :memorialize, params: { id: account.id } } let(:current_user) { Fabricate(:user, admin: current_user_admin) } - let(:account) { Fabricate(:account, user: user) } + let(:account) { user.account } let(:user) { Fabricate(:user, admin: target_user_admin) } context 'when user is admin' do @@ -125,7 +125,7 @@ RSpec.describe Admin::AccountsController, type: :controller do subject { post :enable, params: { id: account.id } } let(:current_user) { Fabricate(:user, admin: admin) } - let(:account) { Fabricate(:account, user: user) } + let(:account) { user.account } let(:user) { Fabricate(:user, disabled: true) } context 'when user is admin' do diff --git a/spec/controllers/admin/change_email_controller_spec.rb b/spec/controllers/admin/change_email_controller_spec.rb index 31df0f0fc..e7f3f7c97 100644 --- a/spec/controllers/admin/change_email_controller_spec.rb +++ b/spec/controllers/admin/change_email_controller_spec.rb @@ -11,10 +11,9 @@ RSpec.describe Admin::ChangeEmailsController, type: :controller do describe "GET #show" do it "returns http success" do - account = Fabricate(:account) - user = Fabricate(:user, account: account) + user = Fabricate(:user) - get :show, params: { account_id: account.id } + get :show, params: { account_id: user.account.id } expect(response).to have_http_status(200) end @@ -26,12 +25,11 @@ RSpec.describe Admin::ChangeEmailsController, type: :controller do end it "returns http success" do - account = Fabricate(:account) - user = Fabricate(:user, account: account) + user = Fabricate(:user) previous_email = user.email - post :update, params: { account_id: account.id, user: { unconfirmed_email: 'test@example.com' } } + post :update, params: { account_id: user.account.id, user: { unconfirmed_email: 'test@example.com' } } user.reload @@ -41,7 +39,7 @@ RSpec.describe Admin::ChangeEmailsController, type: :controller do expect(UserMailer).to have_received(:confirmation_instructions).with(user, user.confirmation_token, { to: 'test@example.com' }) - expect(response).to redirect_to(admin_account_path(account.id)) + expect(response).to redirect_to(admin_account_path(user.account.id)) end end end diff --git a/spec/controllers/admin/confirmations_controller_spec.rb b/spec/controllers/admin/confirmations_controller_spec.rb index eec2b2f5c..5b4f7e925 100644 --- a/spec/controllers/admin/confirmations_controller_spec.rb +++ b/spec/controllers/admin/confirmations_controller_spec.rb @@ -9,9 +9,8 @@ RSpec.describe Admin::ConfirmationsController, type: :controller do describe 'POST #create' do it 'confirms the user' do - account = Fabricate(:account) - user = Fabricate(:user, confirmed_at: false, account: account) - post :create, params: { account_id: account.id } + user = Fabricate(:user, confirmed_at: false) + post :create, params: { account_id: user.account.id } expect(response).to redirect_to(admin_accounts_path) expect(user.reload).to be_confirmed @@ -32,10 +31,9 @@ RSpec.describe Admin::ConfirmationsController, type: :controller do end describe 'POST #resernd' do - subject { post :resend, params: { account_id: account.id } } + subject { post :resend, params: { account_id: user.account.id } } - let(:account) { Fabricate(:account) } - let!(:user) { Fabricate(:user, confirmed_at: confirmed_at, account: account) } + let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) } before do allow(UserMailer).to receive(:confirmation_instructions) { double(:email, deliver_later: nil) } diff --git a/spec/controllers/admin/resets_controller_spec.rb b/spec/controllers/admin/resets_controller_spec.rb index c1e34b7f9..28510b5af 100644 --- a/spec/controllers/admin/resets_controller_spec.rb +++ b/spec/controllers/admin/resets_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Admin::ResetsController do render_views - let(:account) { Fabricate(:account, user: Fabricate(:user)) } + let(:account) { Fabricate(:account) } before do sign_in Fabricate(:user, admin: true), scope: :user end diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb index 05a42d1c1..c286b8cbf 100644 --- a/spec/controllers/api/base_controller_spec.rb +++ b/spec/controllers/api/base_controller_spec.rb @@ -28,7 +28,7 @@ describe Api::BaseController do end describe 'non-functional accounts handling' do - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } controller do diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index 9fb0d8770..b2557d957 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::CredentialsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } context 'with an oauth token' do diff --git a/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb b/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb index 482a19ef2..1e6e1d8e0 100644 --- a/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::FollowerAccountsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') } let(:account) { Fabricate(:account) } let(:alice) { Fabricate(:account) } @@ -49,10 +49,10 @@ describe Api::V1::Accounts::FollowerAccountsController do end context 'when requesting user is the account owner' do - let(:user) { Fabricate(:user, account: account) } + let(:user) { account.user } it 'returns all accounts, including muted accounts' do - user.account.mute!(bob) + account.mute!(bob) get :index, params: { account_id: account.id, limit: 2 } expect(body_as_json.size).to eq 2 diff --git a/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb b/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb index e35b625fe..cc962c6ee 100644 --- a/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::FollowingAccountsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') } let(:account) { Fabricate(:account) } let(:alice) { Fabricate(:account) } @@ -49,10 +49,10 @@ describe Api::V1::Accounts::FollowingAccountsController do end context 'when requesting user is the account owner' do - let(:user) { Fabricate(:user, account: account) } + let(:user) { account.user } it 'returns all accounts, including muted accounts' do - user.account.mute!(bob) + account.mute!(bob) get :index, params: { account_id: account.id, limit: 2 } expect(body_as_json.size).to eq 2 diff --git a/spec/controllers/api/v1/accounts/lists_controller_spec.rb b/spec/controllers/api/v1/accounts/lists_controller_spec.rb index baafea8e6..d71485633 100644 --- a/spec/controllers/api/v1/accounts/lists_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/lists_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::ListsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:lists') } let(:account) { Fabricate(:account) } let(:list) { Fabricate(:list, account: user.account) } diff --git a/spec/controllers/api/v1/accounts/notes_controller_spec.rb b/spec/controllers/api/v1/accounts/notes_controller_spec.rb index 0a2957fed..47d595c70 100644 --- a/spec/controllers/api/v1/accounts/notes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/notes_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::NotesController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:accounts') } let(:account) { Fabricate(:account) } let(:comment) { 'foo' } diff --git a/spec/controllers/api/v1/accounts/pins_controller_spec.rb b/spec/controllers/api/v1/accounts/pins_controller_spec.rb index c71935df2..19bba093e 100644 --- a/spec/controllers/api/v1/accounts/pins_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/pins_controller_spec.rb @@ -3,8 +3,8 @@ require 'rails_helper' RSpec.describe Api::V1::Accounts::PinsController, type: :controller do - let(:john) { Fabricate(:user, account: Fabricate(:account, username: 'john')) } - let(:kevin) { Fabricate(:user, account: Fabricate(:account, username: 'kevin')) } + let(:john) { Fabricate(:user) } + let(:kevin) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: john.id, scopes: 'write:accounts') } before do diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index fe715ff62..69ad0d061 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::RelationshipsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:follows') } before do @@ -11,8 +11,8 @@ describe Api::V1::Accounts::RelationshipsController do end describe 'GET #index' do - let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account } - let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account } + let(:simon) { Fabricate(:account) } + let(:lewis) { Fabricate(:account) } before do user.account.follow!(simon) diff --git a/spec/controllers/api/v1/accounts/search_controller_spec.rb b/spec/controllers/api/v1/accounts/search_controller_spec.rb index 8ff2b17de..5b23bff68 100644 --- a/spec/controllers/api/v1/accounts/search_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/search_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Accounts::SearchController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') } before do diff --git a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb index 0a18ddcbd..348de08c2 100644 --- a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Accounts::StatusesController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') } before do diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 9a5a7c72a..5d5c245c5 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::AccountsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:scopes) { '' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -69,7 +69,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #follow' do let(:scopes) { 'write:follows' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', locked: locked)).account } + let(:other_account) { Fabricate(:account, username: 'bob', locked: locked) } context do before do @@ -150,7 +150,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #unfollow' do let(:scopes) { 'write:follows' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) @@ -170,7 +170,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #remove_from_followers' do let(:scopes) { 'write:follows' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do other_account.follow!(user.account) @@ -190,7 +190,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #block' do let(:scopes) { 'write:blocks' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) @@ -214,7 +214,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #unblock' do let(:scopes) { 'write:blocks' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.block!(other_account) @@ -234,7 +234,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #mute' do let(:scopes) { 'write:mutes' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) @@ -262,7 +262,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #mute with notifications set to false' do let(:scopes) { 'write:mutes' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) @@ -290,7 +290,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #mute with nonzero duration set' do let(:scopes) { 'write:mutes' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) @@ -318,7 +318,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do describe 'POST #unmute' do let(:scopes) { 'write:mutes' } - let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.mute!(other_account) diff --git a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb index a5a8f4bb0..601290b82 100644 --- a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb +++ b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb @@ -4,10 +4,10 @@ RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do render_views let(:role) { 'moderator' } - let(:user) { Fabricate(:user, role: role, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:account) { Fabricate(:user).account } + let(:account) { Fabricate(:account) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/admin/accounts_controller_spec.rb b/spec/controllers/api/v1/admin/accounts_controller_spec.rb index f6be35f7f..bf79ee520 100644 --- a/spec/controllers/api/v1/admin/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/admin/accounts_controller_spec.rb @@ -4,10 +4,10 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do render_views let(:role) { 'moderator' } - let(:user) { Fabricate(:user, role: role, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:account) { Fabricate(:user).account } + let(:account) { Fabricate(:account) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/admin/reports_controller_spec.rb b/spec/controllers/api/v1/admin/reports_controller_spec.rb index 4ed3c5dc4..b6df53048 100644 --- a/spec/controllers/api/v1/admin/reports_controller_spec.rb +++ b/spec/controllers/api/v1/admin/reports_controller_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do render_views let(:role) { 'moderator' } - let(:user) { Fabricate(:user, role: role, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } let(:report) { Fabricate(:report) } diff --git a/spec/controllers/api/v1/blocks_controller_spec.rb b/spec/controllers/api/v1/blocks_controller_spec.rb index 818f76c92..0e5c8296d 100644 --- a/spec/controllers/api/v1/blocks_controller_spec.rb +++ b/spec/controllers/api/v1/blocks_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::BlocksController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:scopes) { 'read:blocks' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb index 070f65061..5add7cf1d 100644 --- a/spec/controllers/api/v1/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/conversations_controller_spec.rb @@ -3,9 +3,9 @@ require 'rails_helper' RSpec.describe Api::V1::ConversationsController, type: :controller do render_views - let!(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let!(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:other) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) } + let(:other) { Fabricate(:user) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/domain_blocks_controller_spec.rb index 6a7a35c7a..d9dc1bdbf 100644 --- a/spec/controllers/api/v1/domain_blocks_controller_spec.rb +++ b/spec/controllers/api/v1/domain_blocks_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::DomainBlocksController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } before do diff --git a/spec/controllers/api/v1/follow_requests_controller_spec.rb b/spec/controllers/api/v1/follow_requests_controller_spec.rb index 1034faa32..856ba2a1c 100644 --- a/spec/controllers/api/v1/follow_requests_controller_spec.rb +++ b/spec/controllers/api/v1/follow_requests_controller_spec.rb @@ -3,9 +3,9 @@ require 'rails_helper' RSpec.describe Api::V1::FollowRequestsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice', locked: true)) } + let(:user) { Fabricate(:user, account_attributes: { locked: true }) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:follower) { Fabricate(:account, username: 'bob') } + let(:follower) { Fabricate(:account) } before do FollowService.new.call(follower, user.account) diff --git a/spec/controllers/api/v1/instances_controller_spec.rb b/spec/controllers/api/v1/instances_controller_spec.rb index 7397d25d6..842669d96 100644 --- a/spec/controllers/api/v1/instances_controller_spec.rb +++ b/spec/controllers/api/v1/instances_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe Api::V1::InstancesController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) } before do diff --git a/spec/controllers/api/v1/lists/accounts_controller_spec.rb b/spec/controllers/api/v1/lists/accounts_controller_spec.rb index 08c22de56..526d8b561 100644 --- a/spec/controllers/api/v1/lists/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/lists/accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Api::V1::Lists::AccountsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } let(:list) { Fabricate(:list, account: user.account) } diff --git a/spec/controllers/api/v1/lists_controller_spec.rb b/spec/controllers/api/v1/lists_controller_spec.rb index e92213789..71a8094e6 100644 --- a/spec/controllers/api/v1/lists_controller_spec.rb +++ b/spec/controllers/api/v1/lists_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::ListsController, type: :controller do render_views - let!(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let!(:user) { Fabricate(:user) } let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } let!(:list) { Fabricate(:list, account: user.account) } diff --git a/spec/controllers/api/v1/markers_controller_spec.rb b/spec/controllers/api/v1/markers_controller_spec.rb index 556a75b9b..ba0f3c322 100644 --- a/spec/controllers/api/v1/markers_controller_spec.rb +++ b/spec/controllers/api/v1/markers_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::MarkersController, type: :controller do render_views - let!(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let!(:user) { Fabricate(:user) } let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses write:statuses') } before { allow(controller).to receive(:doorkeeper_token) { token } } diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb index 3eb015a1c..d8d732630 100644 --- a/spec/controllers/api/v1/media_controller_spec.rb +++ b/spec/controllers/api/v1/media_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::MediaController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:media') } before do diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb index a2b814a69..8176815d4 100644 --- a/spec/controllers/api/v1/mutes_controller_spec.rb +++ b/spec/controllers/api/v1/mutes_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::MutesController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:scopes) { 'read:mutes' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } diff --git a/spec/controllers/api/v1/notifications_controller_spec.rb b/spec/controllers/api/v1/notifications_controller_spec.rb index 5a0b24bbf..f8df6589f 100644 --- a/spec/controllers/api/v1/notifications_controller_spec.rb +++ b/spec/controllers/api/v1/notifications_controller_spec.rb @@ -3,10 +3,10 @@ require 'rails_helper' RSpec.describe Api::V1::NotificationsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:other) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) } - let(:third) { Fabricate(:user, account: Fabricate(:account, username: 'carol')) } + let(:other) { Fabricate(:user) } + let(:third) { Fabricate(:user) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/polls/votes_controller_spec.rb b/spec/controllers/api/v1/polls/votes_controller_spec.rb index 0ee3aa040..d7a9c1970 100644 --- a/spec/controllers/api/v1/polls/votes_controller_spec.rb +++ b/spec/controllers/api/v1/polls/votes_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Polls::VotesController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:scopes) { 'write:statuses' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } diff --git a/spec/controllers/api/v1/polls_controller_spec.rb b/spec/controllers/api/v1/polls_controller_spec.rb index 851bccb7e..f0d9eaf92 100644 --- a/spec/controllers/api/v1/polls_controller_spec.rb +++ b/spec/controllers/api/v1/polls_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::PollsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:scopes) { 'read:statuses' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index a3596cf8a..a13de1370 100644 --- a/spec/controllers/api/v1/reports_controller_spec.rb +++ b/spec/controllers/api/v1/reports_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe Api::V1::ReportsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } before do diff --git a/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb index 7c75a4f73..46d7b6c0a 100644 --- a/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::BookmarksController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:bookmarks', application: app) } diff --git a/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb index f053ae573..439a4738d 100644 --- a/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Statuses::FavouritedByAccountsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') } let(:alice) { Fabricate(:account) } diff --git a/spec/controllers/api/v1/statuses/favourites_controller_spec.rb b/spec/controllers/api/v1/statuses/favourites_controller_spec.rb index 4716ecae3..609957e3e 100644 --- a/spec/controllers/api/v1/statuses/favourites_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/favourites_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::FavouritesController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:favourites', application: app) } diff --git a/spec/controllers/api/v1/statuses/histories_controller_spec.rb b/spec/controllers/api/v1/statuses/histories_controller_spec.rb index 8d9d6a359..00677f1d2 100644 --- a/spec/controllers/api/v1/statuses/histories_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/histories_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::HistoriesController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } diff --git a/spec/controllers/api/v1/statuses/mutes_controller_spec.rb b/spec/controllers/api/v1/statuses/mutes_controller_spec.rb index 966398580..bffa9fe0d 100644 --- a/spec/controllers/api/v1/statuses/mutes_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/mutes_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::MutesController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:mutes', application: app) } diff --git a/spec/controllers/api/v1/statuses/pins_controller_spec.rb b/spec/controllers/api/v1/statuses/pins_controller_spec.rb index 13405d285..8bdaf8b54 100644 --- a/spec/controllers/api/v1/statuses/pins_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/pins_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::PinsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:accounts', application: app) } diff --git a/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb index 60908b7b3..31320349d 100644 --- a/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Statuses::RebloggedByAccountsController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') } let(:alice) { Fabricate(:account) } diff --git a/spec/controllers/api/v1/statuses/reblogs_controller_spec.rb b/spec/controllers/api/v1/statuses/reblogs_controller_spec.rb index f1d3d949c..6eac02b23 100644 --- a/spec/controllers/api/v1/statuses/reblogs_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/reblogs_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::ReblogsController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:statuses', application: app) } diff --git a/spec/controllers/api/v1/statuses/sources_controller_spec.rb b/spec/controllers/api/v1/statuses/sources_controller_spec.rb index 293c90ec9..fbe6fa0be 100644 --- a/spec/controllers/api/v1/statuses/sources_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/sources_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Statuses::SourcesController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index df8037038..2679ab017 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::StatusesController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: scopes) } diff --git a/spec/controllers/api/v1/timelines/home_controller_spec.rb b/spec/controllers/api/v1/timelines/home_controller_spec.rb index e953e4649..131c2d92f 100644 --- a/spec/controllers/api/v1/timelines/home_controller_spec.rb +++ b/spec/controllers/api/v1/timelines/home_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Timelines::HomeController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice'), current_sign_in_at: 1.day.ago) } + let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/timelines/list_controller_spec.rb b/spec/controllers/api/v1/timelines/list_controller_spec.rb index 45e4bf34c..526c66a05 100644 --- a/spec/controllers/api/v1/timelines/list_controller_spec.rb +++ b/spec/controllers/api/v1/timelines/list_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Timelines::ListController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:list) { Fabricate(:list, account: user.account) } before do @@ -30,7 +30,7 @@ describe Api::V1::Timelines::ListController do end context 'with the wrong user context' do - let(:other_user) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) } + let(:other_user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: other_user.id, scopes: 'read') } describe 'GET #show' do diff --git a/spec/controllers/api/v1/timelines/public_controller_spec.rb b/spec/controllers/api/v1/timelines/public_controller_spec.rb index 737aedba6..31e594d22 100644 --- a/spec/controllers/api/v1/timelines/public_controller_spec.rb +++ b/spec/controllers/api/v1/timelines/public_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Timelines::PublicController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v1/timelines/tag_controller_spec.rb b/spec/controllers/api/v1/timelines/tag_controller_spec.rb index f71ca2a39..718911083 100644 --- a/spec/controllers/api/v1/timelines/tag_controller_spec.rb +++ b/spec/controllers/api/v1/timelines/tag_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Api::V1::Timelines::TagController do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/api/v2/search_controller_spec.rb b/spec/controllers/api/v2/search_controller_spec.rb index 8ee8753de..fa20e1e51 100644 --- a/spec/controllers/api/v2/search_controller_spec.rb +++ b/spec/controllers/api/v2/search_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe Api::V2::SearchController, type: :controller do render_views - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:search') } before do diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 458298a6b..0fb4ddbcf 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -49,7 +49,7 @@ describe ApplicationController, type: :controller do it 'returns account if signed in' do account = Fabricate(:account) - sign_in(Fabricate(:user, account: account)) + sign_in(account.user) expect(controller.view_context.current_account).to eq account end end @@ -164,13 +164,13 @@ describe ApplicationController, type: :controller do end it 'does nothing if user who signed in is not suspended' do - sign_in(Fabricate(:user, account: Fabricate(:account, suspended: false))) + sign_in(Fabricate(:account, suspended: false).user) get 'success' expect(response).to have_http_status(200) end it 'redirects to account status page' do - sign_in(Fabricate(:user, account: Fabricate(:account, suspended: true))) + sign_in(Fabricate(:account, suspended: true).user) get 'success' expect(response).to redirect_to(edit_user_registration_path) end diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index ccf304a93..0ebf6641f 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -228,7 +228,7 @@ RSpec.describe Auth::RegistrationsController, type: :controller do end it 'does nothing if user already exists' do - Fabricate(:user, account: Fabricate(:account, username: 'test')) + Fabricate(:account, username: 'test') subject end diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index 2368cc2bf..64ec7b794 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -37,8 +37,11 @@ RSpec.describe Auth::SessionsController, type: :controller do end context 'with a suspended user' do + before do + user.account.suspend! + end + it 'redirects to home after sign out' do - Fabricate(:account, user: user, suspended: true) sign_in(user, scope: :user) delete :destroy @@ -78,8 +81,8 @@ RSpec.describe Auth::SessionsController, type: :controller do end context 'using a valid email and existing user' do - let(:user) do - account = Fabricate.build(:account, username: 'pam_user1') + let!(:user) do + account = Fabricate.build(:account, username: 'pam_user1', user: nil) account.save!(validate: false) user = Fabricate(:user, email: 'pam@example.com', password: nil, account: account, external: true) user diff --git a/spec/controllers/authorize_interactions_controller_spec.rb b/spec/controllers/authorize_interactions_controller_spec.rb index b4ce30cd7..99f3f6ffc 100644 --- a/spec/controllers/authorize_interactions_controller_spec.rb +++ b/spec/controllers/authorize_interactions_controller_spec.rb @@ -16,7 +16,6 @@ describe AuthorizeInteractionsController do describe 'when signed in' do let(:user) { Fabricate(:user) } - let(:account) { Fabricate(:account, user: user) } before do sign_in(user) @@ -76,7 +75,7 @@ describe AuthorizeInteractionsController do describe 'when signed in' do let!(:user) { Fabricate(:user) } - let!(:account) { user.account } + let(:account) { user.account } before do sign_in(user) diff --git a/spec/controllers/concerns/account_controller_concern_spec.rb b/spec/controllers/concerns/account_controller_concern_spec.rb index 7ea214a7d..835645414 100644 --- a/spec/controllers/concerns/account_controller_concern_spec.rb +++ b/spec/controllers/concerns/account_controller_concern_spec.rb @@ -17,7 +17,7 @@ describe ApplicationController, type: :controller do context 'when account is suspended' do it 'returns http gone' do - account = Fabricate(:account, suspended: true, user: Fabricate(:user)) + account = Fabricate(:account, suspended: true) get 'success', params: { account_username: account.username } expect(response).to have_http_status(410) end @@ -33,19 +33,19 @@ describe ApplicationController, type: :controller do context 'when account is not suspended' do it 'assigns @account' do - account = Fabricate(:account, user: Fabricate(:user)) + account = Fabricate(:account) get 'success', params: { account_username: account.username } expect(assigns(:account)).to eq account end it 'sets link headers' do - account = Fabricate(:account, username: 'username', user: Fabricate(:user)) + account = Fabricate(:account, username: 'username') get 'success', params: { account_username: 'username' } expect(response.headers['Link'].to_s).to eq '; rel="lrdd"; type="application/jrd+json", ; rel="alternate"; type="application/activity+json"' end it 'returns http success' do - account = Fabricate(:account, user: Fabricate(:user)) + account = Fabricate(:account) get 'success', params: { account_username: account.username } expect(response).to have_http_status(200) end diff --git a/spec/controllers/concerns/accountable_concern_spec.rb b/spec/controllers/concerns/accountable_concern_spec.rb index e3c06b494..5c5180bc2 100644 --- a/spec/controllers/concerns/accountable_concern_spec.rb +++ b/spec/controllers/concerns/accountable_concern_spec.rb @@ -12,14 +12,14 @@ RSpec.describe AccountableConcern do end end - let(:user) { Fabricate(:user, account: Fabricate(:account)) } - let(:target) { Fabricate(:user, account: Fabricate(:account)) } - let(:hoge) { Hoge.new(user.account) } + let(:user) { Fabricate(:account) } + let(:target) { Fabricate(:account) } + let(:hoge) { Hoge.new(user) } describe '#log_action' do it 'creates Admin::ActionLog' do expect do - hoge.log_action(:create, target.account) + hoge.log_action(:create, target) end.to change { Admin::ActionLog.count }.by(1) end end diff --git a/spec/controllers/follower_accounts_controller_spec.rb b/spec/controllers/follower_accounts_controller_spec.rb index 006274169..eb095cf30 100644 --- a/spec/controllers/follower_accounts_controller_spec.rb +++ b/spec/controllers/follower_accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe FollowerAccountsController do render_views - let(:alice) { Fabricate(:user).account } + let(:alice) { Fabricate(:account) } let(:follower0) { Fabricate(:account) } let(:follower1) { Fabricate(:account) } diff --git a/spec/controllers/following_accounts_controller_spec.rb b/spec/controllers/following_accounts_controller_spec.rb index 7ec0e3d06..af5ce0787 100644 --- a/spec/controllers/following_accounts_controller_spec.rb +++ b/spec/controllers/following_accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe FollowingAccountsController do render_views - let(:alice) { Fabricate(:user).account } + let(:alice) { Fabricate(:account) } let(:followee0) { Fabricate(:account) } let(:followee1) { Fabricate(:account) } diff --git a/spec/controllers/settings/deletes_controller_spec.rb b/spec/controllers/settings/deletes_controller_spec.rb index 92ab401c9..cd36ecc35 100644 --- a/spec/controllers/settings/deletes_controller_spec.rb +++ b/spec/controllers/settings/deletes_controller_spec.rb @@ -17,7 +17,7 @@ describe Settings::DeletesController do end context 'when suspended' do - let(:user) { Fabricate(:user, account_attributes: { username: 'alice', suspended_at: Time.now.utc }) } + let(:user) { Fabricate(:user, account_attributes: { suspended_at: Time.now.utc }) } it 'returns http forbidden' do get :show @@ -64,7 +64,7 @@ describe Settings::DeletesController do end context 'when suspended' do - let(:user) { Fabricate(:user, account_attributes: { username: 'alice', suspended_at: Time.now.utc }) } + let(:user) { Fabricate(:user, account_attributes: { suspended_at: Time.now.utc }) } it 'returns http forbidden' do expect(response).to have_http_status(403) diff --git a/spec/controllers/settings/migrations_controller_spec.rb b/spec/controllers/settings/migrations_controller_spec.rb index 048d9de8d..35c5747a0 100644 --- a/spec/controllers/settings/migrations_controller_spec.rb +++ b/spec/controllers/settings/migrations_controller_spec.rb @@ -19,8 +19,7 @@ describe Settings::MigrationsController do context 'when user is sign in' do subject { get :show } - let(:user) { Fabricate(:user, account: account) } - let(:account) { Fabricate(:account, moved_to_account: moved_to_account) } + let(:user) { Fabricate(:account, moved_to_account: moved_to_account).user } before { sign_in user, scope: :user } diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb index 1ac286254..ee3aec815 100644 --- a/spec/controllers/settings/profiles_controller_spec.rb +++ b/spec/controllers/settings/profiles_controller_spec.rb @@ -3,9 +3,11 @@ require 'rails_helper' RSpec.describe Settings::ProfilesController, type: :controller do render_views + let!(:user) { Fabricate(:user) } + let(:account) { user.account } + before do - @user = Fabricate(:user) - sign_in @user, scope: :user + sign_in user, scope: :user end describe "GET #show" do @@ -16,10 +18,12 @@ RSpec.describe Settings::ProfilesController, type: :controller do end describe 'PUT #update' do + before do + user.account.update(display_name: 'Old name') + end + it 'updates the user profile' do allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) - account = Fabricate(:account, user: @user, display_name: 'Old name') - put :update, params: { account: { display_name: 'New name' } } expect(account.reload.display_name).to eq 'New name' expect(response).to redirect_to(settings_profile_path) @@ -30,7 +34,6 @@ RSpec.describe Settings::ProfilesController, type: :controller do describe 'PUT #update with new profile image' do it 'updates profile image' do allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) - account = Fabricate(:account, user: @user, display_name: 'AvatarTest') expect(account.avatar.instance.avatar_file_name).to be_nil put :update, params: { account: { avatar: fixture_file_upload('avatar.gif', 'image/gif') } } @@ -43,7 +46,6 @@ RSpec.describe Settings::ProfilesController, type: :controller do describe 'PUT #update with oversized image' do it 'gives the user an error message' do allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) - account = Fabricate(:account, user: @user, display_name: 'AvatarTest') put :update, params: { account: { avatar: fixture_file_upload('4096x4097.png', 'image/png') } } expect(response.body).to include('images are not supported') end diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index ab900c5fa..f1cce281c 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -10,4 +10,5 @@ Fabricator(:account) do private_key { private_key } suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil } silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil } + user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil } end diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb index 8f5956501..10ad2c53a 100644 --- a/spec/fabricators/user_fabricator.rb +++ b/spec/fabricators/user_fabricator.rb @@ -1,5 +1,5 @@ Fabricator(:user) do - account + account { Fabricate.build(:account, user: nil) } email { sequence(:email) { |i| "#{i}#{Faker::Internet.email}" } } password "123456789" confirmed_at { Time.zone.now } diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb index 4a8ef7b5e..29fb586a3 100644 --- a/spec/mailers/admin_mailer_spec.rb +++ b/spec/mailers/admin_mailer_spec.rb @@ -4,11 +4,15 @@ require 'rails_helper' RSpec.describe AdminMailer, type: :mailer do describe '.new_report' do - let(:sender) { Fabricate(:account, username: 'John', user: Fabricate(:user)) } - let(:recipient) { Fabricate(:account, username: 'Mike', user: Fabricate(:user, locale: :en)) } + let(:sender) { Fabricate(:account, username: 'John') } + let(:recipient) { Fabricate(:account, username: 'Mike') } let(:report) { Fabricate(:report, account: sender, target_account: recipient) } let(:mail) { described_class.new_report(recipient, report) } + before do + recipient.user.update(locale: :en) + end + it 'renders the headers' do expect(mail.subject).to eq("New report for cb6e6126.ngrok.io (##{report.id})") expect(mail.to).to eq [recipient.user_email] diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 9b645bad8..2ca4e26fa 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe NotificationMailer, type: :mailer do - let(:receiver) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:receiver) { Fabricate(:user) } let(:sender) { Fabricate(:account, username: 'bob') } let(:foreign_status) { Fabricate(:status, account: sender, text: 'The body of the foreign status') } let(:own_status) { Fabricate(:status, account: receiver.account, text: 'The body of the own status') } diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 65e6714c0..681134d49 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Account, type: :model do end context 'when the account is of a local user' do - let!(:subject) { Fabricate(:account, user: Fabricate(:user, email: 'foo+bar@domain.org')) } + let!(:subject) { Fabricate(:user, email: 'foo+bar@domain.org').account } it 'creates a canonical domain block' do subject.suspend! diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index 2366b9ca4..809c7fc46 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -5,8 +5,8 @@ RSpec.describe Admin::AccountAction, type: :model do describe '#save!' do subject { account_action.save! } - let(:account) { Fabricate(:account, user: Fabricate(:user, admin: true)) } - let(:target_account) { Fabricate(:account, user: Fabricate(:user)) } + let(:account) { Fabricate(:user, admin: true).account } + let(:target_account) { Fabricate(:account) } let(:type) { 'disable' } before do diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb index 0392a582c..0ffc343f1 100644 --- a/spec/models/public_feed_spec.rb +++ b/spec/models/public_feed_spec.rb @@ -31,7 +31,6 @@ RSpec.describe PublicFeed, type: :model do end it 'filters out silenced accounts' do - account = Fabricate(:account) silenced_account = Fabricate(:account, silenced: true) status = Fabricate(:status, account: account) silenced_status = Fabricate(:status, account: silenced_account) @@ -176,8 +175,7 @@ RSpec.describe PublicFeed, type: :model do context 'with language preferences' do it 'excludes statuses in languages not allowed by the account user' do - user = Fabricate(:user, chosen_languages: [:en, :es]) - @account.update(user: user) + @account.user.update(chosen_languages: [:en, :es]) en_status = Fabricate(:status, language: 'en') es_status = Fabricate(:status, language: 'es') fr_status = Fabricate(:status, language: 'fr') @@ -188,8 +186,7 @@ RSpec.describe PublicFeed, type: :model do end it 'includes all languages when user does not have a setting' do - user = Fabricate(:user, chosen_languages: nil) - @account.update(user: user) + @account.user.update(chosen_languages: nil) en_status = Fabricate(:status, language: 'en') es_status = Fabricate(:status, language: 'es') @@ -199,7 +196,8 @@ RSpec.describe PublicFeed, type: :model do end it 'includes all languages when account does not have a user' do - expect(@account.user).to be_nil + @account.update(user: nil) + en_status = Fabricate(:status, language: 'en') es_status = Fabricate(:status, language: 'es') diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 54bb6db7f..406438c22 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -194,12 +194,12 @@ RSpec.describe User, type: :model do end it "returns 'private' if user has not configured default privacy setting and account is locked" do - user = Fabricate(:user, account: Fabricate(:account, locked: true)) + user = Fabricate(:account, locked: true).user expect(user.setting_default_privacy).to eq 'private' end it "returns 'public' if user has not configured default privacy setting and account is not locked" do - user = Fabricate(:user, account: Fabricate(:account, locked: false)) + user = Fabricate(:account, locked: false).user expect(user.setting_default_privacy).to eq 'public' end end @@ -248,7 +248,7 @@ RSpec.describe User, type: :model do it_behaves_like 'Settings-extended' do def create! - User.create!(account: Fabricate(:account), email: 'foo@mastodon.space', password: 'abcd1234', agreement: true) + User.create!(account: Fabricate(:account, user: nil), email: 'foo@mastodon.space', password: 'abcd1234', agreement: true) end def fabricate diff --git a/spec/policies/account_moderation_note_policy_spec.rb b/spec/policies/account_moderation_note_policy_spec.rb index bb7af94e4..39ec2008a 100644 --- a/spec/policies/account_moderation_note_policy_spec.rb +++ b/spec/policies/account_moderation_note_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe AccountModerationNotePolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :create? do context 'staff' do @@ -42,7 +42,7 @@ RSpec.describe AccountModerationNotePolicy do end context 'neither admin nor owner' do - let(:kevin) { Fabricate(:user).account } + let(:kevin) { Fabricate(:account) } it 'denies to destroy' do expect(subject).to_not permit(kevin, account_moderation_note) diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb index 8a5e62c06..b55eb65a7 100644 --- a/spec/policies/account_policy_spec.rb +++ b/spec/policies/account_policy_spec.rb @@ -6,8 +6,8 @@ require 'pundit/rspec' RSpec.describe AccountPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } - let(:alice) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } + let(:alice) { Fabricate(:account) } permissions :index? do context 'staff' do diff --git a/spec/policies/backup_policy_spec.rb b/spec/policies/backup_policy_spec.rb index 80407e12f..6b31c6f7c 100644 --- a/spec/policies/backup_policy_spec.rb +++ b/spec/policies/backup_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe BackupPolicy do let(:subject) { described_class } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :create? do context 'not user_signed_in?' do diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb index 8def88212..e4f1af3c1 100644 --- a/spec/policies/custom_emoji_policy_spec.rb +++ b/spec/policies/custom_emoji_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe CustomEmojiPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :index?, :enable?, :disable? do context 'staff' do diff --git a/spec/policies/domain_block_policy_spec.rb b/spec/policies/domain_block_policy_spec.rb index aea50ec0f..b24ed9e3a 100644 --- a/spec/policies/domain_block_policy_spec.rb +++ b/spec/policies/domain_block_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe DomainBlockPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :index?, :show?, :create?, :destroy? do context 'admin' do diff --git a/spec/policies/email_domain_block_policy_spec.rb b/spec/policies/email_domain_block_policy_spec.rb index a3e825e07..1ff55af8e 100644 --- a/spec/policies/email_domain_block_policy_spec.rb +++ b/spec/policies/email_domain_block_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe EmailDomainBlockPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :index?, :create?, :destroy? do context 'admin' do diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb index 72cf25f56..71ef1fe50 100644 --- a/spec/policies/instance_policy_spec.rb +++ b/spec/policies/instance_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe InstancePolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :index?, :show?, :destroy? do context 'admin' do diff --git a/spec/policies/invite_policy_spec.rb b/spec/policies/invite_policy_spec.rb index e391455be..122137804 100644 --- a/spec/policies/invite_policy_spec.rb +++ b/spec/policies/invite_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe InvitePolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :index? do context 'staff?' do diff --git a/spec/policies/relay_policy_spec.rb b/spec/policies/relay_policy_spec.rb index 640f27d54..139d945dc 100644 --- a/spec/policies/relay_policy_spec.rb +++ b/spec/policies/relay_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe RelayPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :update? do context 'admin?' do diff --git a/spec/policies/report_note_policy_spec.rb b/spec/policies/report_note_policy_spec.rb index 596d7d7a9..c34f99b71 100644 --- a/spec/policies/report_note_policy_spec.rb +++ b/spec/policies/report_note_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe ReportNotePolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :create? do context 'staff?' do diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb index c9ae1e87a..84c366d7f 100644 --- a/spec/policies/report_policy_spec.rb +++ b/spec/policies/report_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe ReportPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :update?, :index?, :show? do context 'staff?' do diff --git a/spec/policies/settings_policy_spec.rb b/spec/policies/settings_policy_spec.rb index 92f1f4869..3fa183c50 100644 --- a/spec/policies/settings_policy_spec.rb +++ b/spec/policies/settings_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe SettingsPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :update?, :show? do context 'admin?' do diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb index c63875dc0..256e6786a 100644 --- a/spec/policies/tag_policy_spec.rb +++ b/spec/policies/tag_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe TagPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :index?, :show?, :update? do context 'staff?' do diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index e37904f04..1933ee014 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe UserPolicy do let(:subject) { described_class } let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:user).account } + let(:john) { Fabricate(:account) } permissions :reset_password?, :change_email? do context 'staff?' do diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb index 8e5d8fb03..888d694b6 100644 --- a/spec/services/authorize_follow_service_spec.rb +++ b/spec/services/authorize_follow_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe AuthorizeFollowService, type: :service do subject { AuthorizeFollowService.new } describe 'local' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do FollowRequest.create(account: bob, target_account: sender) @@ -23,7 +23,7 @@ RSpec.describe AuthorizeFollowService, type: :service do end describe 'remote ActivityPub' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } before do FollowRequest.create(account: bob, target_account: sender) diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 4203952c6..8f38908cd 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -5,7 +5,7 @@ RSpec.describe BatchedRemoveStatusService, type: :service do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } - let!(:jeff) { Fabricate(:user).account } + let!(:jeff) { Fabricate(:account) } let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status1) { PostStatusService.new.call(alice, text: 'Hello @bob@example.com') } diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb index 3714f09e9..a53e1f928 100644 --- a/spec/services/block_service_spec.rb +++ b/spec/services/block_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe BlockService, type: :service do subject { BlockService.new } describe 'local' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do subject.call(sender, bob) @@ -18,7 +18,7 @@ RSpec.describe BlockService, type: :service do end describe 'remote ActivityPub' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do stub_request(:post, 'http://example.com/inbox').to_return(status: 200) diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb index 4ce110e45..aaf179ce5 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -3,9 +3,9 @@ require 'rails_helper' RSpec.describe FanOutOnWriteService, type: :service do let(:last_active_at) { Time.now.utc } - let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'alice')).account } - let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'bob')).account } - let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'tom')).account } + let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at).account } + let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account_attributes: { username: 'bob' }).account } + let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at).account } subject { described_class.new } diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index fc7f58eb4..94a8111dd 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe FavouriteService, type: :service do subject { FavouriteService.new } describe 'local' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account) } let(:status) { Fabricate(:status, account: bob) } before do @@ -19,7 +19,7 @@ RSpec.describe FavouriteService, type: :service do end describe 'remote ActivityPub' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status) { Fabricate(:status, account: bob) } before do diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index 63d6eb3bd..02bc87c58 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe FollowService, type: :service do context 'local account' do describe 'locked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob')).account } + let(:bob) { Fabricate(:account, locked: true, username: 'bob') } before do subject.call(sender, bob) @@ -19,7 +19,7 @@ RSpec.describe FollowService, type: :service do end describe 'locked account, no reblogs' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob')).account } + let(:bob) { Fabricate(:account, locked: true, username: 'bob') } before do subject.call(sender, bob, reblogs: false) @@ -31,7 +31,7 @@ RSpec.describe FollowService, type: :service do end describe 'unlocked account, from silenced account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do sender.touch(:silenced_at) @@ -44,7 +44,7 @@ RSpec.describe FollowService, type: :service do end describe 'unlocked account, from a muted account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do bob.mute!(sender) @@ -58,7 +58,7 @@ RSpec.describe FollowService, type: :service do end describe 'unlocked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do subject.call(sender, bob) @@ -71,7 +71,7 @@ RSpec.describe FollowService, type: :service do end describe 'unlocked account, no reblogs' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do subject.call(sender, bob, reblogs: false) @@ -84,7 +84,7 @@ RSpec.describe FollowService, type: :service do end describe 'already followed account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do sender.follow!(bob) @@ -97,7 +97,7 @@ RSpec.describe FollowService, type: :service do end describe 'already followed account, turning reblogs off' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do sender.follow!(bob, reblogs: true) @@ -110,7 +110,7 @@ RSpec.describe FollowService, type: :service do end describe 'already followed account, turning reblogs on' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do sender.follow!(bob, reblogs: false) @@ -124,7 +124,7 @@ RSpec.describe FollowService, type: :service do end context 'remote ActivityPub account' do - let(:bob) { Fabricate(:user, account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } before do stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb index 732cb07f7..e14bfa78d 100644 --- a/spec/services/reject_follow_service_spec.rb +++ b/spec/services/reject_follow_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe RejectFollowService, type: :service do subject { RejectFollowService.new } describe 'local' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account) } before do FollowRequest.create(account: bob, target_account: sender) @@ -23,7 +23,7 @@ RSpec.describe RejectFollowService, type: :service do end describe 'remote ActivityPub' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } before do FollowRequest.create(account: bob, target_account: sender) diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index 21fb0cd35..fb7c6b462 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe RemoveStatusService, type: :service do subject { RemoveStatusService.new } - let!(:alice) { Fabricate(:account, user: Fabricate(:user)) } + let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let!(:jeff) { Fabricate(:account) } let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb index 454e4d896..7e6a113e0 100644 --- a/spec/services/report_service_spec.rb +++ b/spec/services/report_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe ReportService, type: :service do subject { described_class.new } - let(:source_account) { Fabricate(:user).account } + let(:source_account) { Fabricate(:account) } context 'for a remote account' do let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb index c43ab24b0..10448b340 100644 --- a/spec/services/unblock_service_spec.rb +++ b/spec/services/unblock_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe UnblockService, type: :service do subject { UnblockService.new } describe 'local' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account) } before do sender.block!(bob) @@ -19,7 +19,7 @@ RSpec.describe UnblockService, type: :service do end describe 'remote ActivityPub' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do sender.block!(bob) diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 7f0b575e4..bb5bef5c9 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe UnfollowService, type: :service do subject { UnfollowService.new } describe 'local' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:bob) { Fabricate(:account, username: 'bob') } before do sender.follow!(bob) @@ -19,7 +19,7 @@ RSpec.describe UnfollowService, type: :service do end describe 'remote ActivityPub' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do sender.follow!(bob) @@ -37,7 +37,7 @@ RSpec.describe UnfollowService, type: :service do end describe 'remote ActivityPub (reverse)' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do bob.follow!(sender) diff --git a/spec/services/update_account_service_spec.rb b/spec/services/update_account_service_spec.rb index 960b26891..c2dc791e4 100644 --- a/spec/services/update_account_service_spec.rb +++ b/spec/services/update_account_service_spec.rb @@ -5,9 +5,9 @@ RSpec.describe UpdateAccountService, type: :service do describe 'switching form locked to unlocked accounts' do let(:account) { Fabricate(:account, locked: true) } - let(:alice) { Fabricate(:user, email: 'alice@example.com', account: Fabricate(:account, username: 'alice')).account } - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } - let(:eve) { Fabricate(:user, email: 'eve@example.com', account: Fabricate(:account, username: 'eve')).account } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:eve) { Fabricate(:account) } before do bob.touch(:silenced_at) diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb index 82449b0c7..4db5810f1 100644 --- a/spec/workers/move_worker_spec.rb +++ b/spec/workers/move_worker_spec.rb @@ -3,9 +3,9 @@ require 'rails_helper' describe MoveWorker do - let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } - let(:blocking_account) { Fabricate(:user, email: 'bar@example.com', account: Fabricate(:account, username: 'bar')).account } - let(:muting_account) { Fabricate(:user, email: 'foo@example.com', account: Fabricate(:account, username: 'foo')).account } + let(:local_follower) { Fabricate(:account) } + let(:blocking_account) { Fabricate(:account) } + let(:muting_account) { Fabricate(:account) } let(:source_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') } let(:target_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') } let(:local_user) { Fabricate(:user) } @@ -88,7 +88,7 @@ describe MoveWorker do end context 'target account is local' do - let(:target_account) { Fabricate(:user, email: 'alice@example.com', account: Fabricate(:account, username: 'alice')).account } + let(:target_account) { Fabricate(:account) } describe 'perform' do it 'calls UnfollowFollowWorker' do @@ -102,8 +102,8 @@ describe MoveWorker do end context 'both target and source accounts are local' do - let(:target_account) { Fabricate(:user, email: 'alice@example.com', account: Fabricate(:account, username: 'alice')).account } - let(:source_account) { Fabricate(:user, email: 'alice_@example.com', account: Fabricate(:account, username: 'alice_')).account } + let(:target_account) { Fabricate(:account) } + let(:source_account) { Fabricate(:account) } describe 'perform' do it 'calls makes local followers follow the target account' do @@ -115,7 +115,7 @@ describe MoveWorker do include_examples 'block and mute handling' it 'does not fail when a local user is already following both accounts' do - double_follower = Fabricate(:user, email: 'eve@example.com', account: Fabricate(:account, username: 'eve')).account + double_follower = Fabricate(:account) double_follower.follow!(source_account) double_follower.follow!(target_account) subject.perform(source_account.id, target_account.id) diff --git a/spec/workers/unfollow_follow_worker_spec.rb b/spec/workers/unfollow_follow_worker_spec.rb index 5052c5616..5ea4256a9 100644 --- a/spec/workers/unfollow_follow_worker_spec.rb +++ b/spec/workers/unfollow_follow_worker_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe UnfollowFollowWorker do - let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + let(:local_follower) { Fabricate(:account) } let(:source_account) { Fabricate(:account) } let(:target_account) { Fabricate(:account) } let(:show_reblogs) { true } -- cgit