From 4ac78e2a066508a54de82f1d910ef2fd36c3d106 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 9 Aug 2021 23:11:50 +0200 Subject: Add feature to automatically delete old toots (#16529) * Add account statuses cleanup policy model * Record last inspected toot to delete to speed up successive calls to statuses_to_delete * Add service to cleanup a given account's statuses within a budget * Add worker to go through account policies and delete old toots * Fix last inspected status id logic All existing statuses older or equal to last inspected status id must be kept by the current policy. This is an invariant that must be kept so that resuming deletion from the last inspected status remains sound. * Add tests * Refactor scheduler and add tests * Add user interface * Add support for discriminating based on boosts/favs * Add UI support for min_reblogs and min_favs, rework UI * Address first round of review comments * Replace Snowflake#id_at_start with with_random parameter * Add tests * Add tests for StatusesCleanupController * Rework settings page * Adjust load-avoiding mechanisms * Please CodeClimate --- .../accounts_statuses_cleanup_scheduler.rb | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb (limited to 'app/workers/scheduler') diff --git a/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb b/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb new file mode 100644 index 000000000..f42d4bca6 --- /dev/null +++ b/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Scheduler::AccountsStatusesCleanupScheduler + include Sidekiq::Worker + + # This limit is mostly to be nice to the fediverse at large and not + # generate too much traffic. + # This also helps limiting the running time of the scheduler itself. + MAX_BUDGET = 50 + + # This is an attempt to spread the load across instances, as various + # accounts are likely to have various followers. + PER_ACCOUNT_BUDGET = 5 + + # This is an attempt to limit the workload generated by status removal + # jobs to something the particular instance can handle. + PER_THREAD_BUDGET = 5 + + # Those avoid loading an instance that is already under load + MAX_DEFAULT_SIZE = 2 + MAX_DEFAULT_LATENCY = 5 + MAX_PUSH_SIZE = 5 + MAX_PUSH_LATENCY = 10 + # 'pull' queue has lower priority jobs, and it's unlikely that pushing + # deletes would cause much issues with this queue if it didn't cause issues + # with default and push. Yet, do not enqueue deletes if the instance is + # lagging behind too much. + MAX_PULL_SIZE = 500 + MAX_PULL_LATENCY = 300 + + # This is less of an issue in general, but deleting old statuses is likely + # to cause delivery errors, and thus increase the number of jobs to be retried. + # This doesn't directly translate to load, but connection errors and a high + # number of dead instances may lead to this spiraling out of control if + # unchecked. + MAX_RETRY_SIZE = 50_000 + + sidekiq_options retry: 0, lock: :until_executed + + def perform + return if under_load? + + budget = compute_budget + first_policy_id = last_processed_id + + loop do + num_processed_accounts = 0 + + scope = AccountStatusesCleanupPolicy.where(enabled: true) + scope.where(Account.arel_table[:id].gt(first_policy_id)) if first_policy_id.present? + scope.find_each(order: :asc) do |policy| + num_deleted = AccountStatusesCleanupService.new.call(policy, [budget, PER_ACCOUNT_BUDGET].min) + num_processed_accounts += 1 unless num_deleted.zero? + budget -= num_deleted + if budget.zero? + save_last_processed_id(policy.id) + break + end + end + + # The idea here is to loop through all policies at least once until the budget is exhausted + # and start back after the last processed account otherwise + break if budget.zero? || (num_processed_accounts.zero? && first_policy_id.nil?) + first_policy_id = nil + end + end + + def compute_budget + threads = Sidekiq::ProcessSet.new.filter { |x| x['queues'].include?('push') }.map { |x| x['concurrency'] }.sum + [PER_THREAD_BUDGET * threads, MAX_BUDGET].min + end + + def under_load? + return true if Sidekiq::Stats.new.retry_size > MAX_RETRY_SIZE + queue_under_load?('default', MAX_DEFAULT_SIZE, MAX_DEFAULT_LATENCY) || queue_under_load?('push', MAX_PUSH_SIZE, MAX_PUSH_LATENCY) || queue_under_load?('pull', MAX_PULL_SIZE, MAX_PULL_LATENCY) + end + + private + + def queue_under_load?(name, max_size, max_latency) + queue = Sidekiq::Queue.new(name) + queue.size > max_size || queue.latency > max_latency + end + + def last_processed_id + Redis.current.get('account_statuses_cleanup_scheduler:last_account_id') + end + + def save_last_processed_id(id) + if id.nil? + Redis.current.del('account_statuses_cleanup_scheduler:last_account_id') + else + Redis.current.set('account_statuses_cleanup_scheduler:last_account_id', id, ex: 1.hour.seconds) + 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 'app/workers/scheduler') 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 bda8e4f815708bd4deeb3c2310732e0b7a4e15e8 Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Tue, 14 Dec 2021 07:21:14 +0900 Subject: Fix follow recommendation biased towards older accounts (#17126) --- .../scheduler/follow_recommendations_scheduler.rb | 4 ++-- ...040746_update_account_summaries_to_version_2.rb | 24 ++++++++++++++++++++++ db/schema.rb | 4 ++-- db/views/account_summaries_v02.sql | 23 +++++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20211213040746_update_account_summaries_to_version_2.rb create mode 100644 db/views/account_summaries_v02.sql (limited to 'app/workers/scheduler') diff --git a/app/workers/scheduler/follow_recommendations_scheduler.rb b/app/workers/scheduler/follow_recommendations_scheduler.rb index cb1e15961..effc63e59 100644 --- a/app/workers/scheduler/follow_recommendations_scheduler.rb +++ b/app/workers/scheduler/follow_recommendations_scheduler.rb @@ -16,12 +16,12 @@ class Scheduler::FollowRecommendationsScheduler AccountSummary.refresh FollowRecommendation.refresh - fallback_recommendations = FollowRecommendation.limit(SET_SIZE).index_by(&:account_id) + fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE).index_by(&:account_id) I18n.available_locales.each do |locale| recommendations = begin if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist - FollowRecommendation.localized(locale).limit(SET_SIZE).index_by(&:account_id) + FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).index_by(&:account_id) else {} end diff --git a/db/migrate/20211213040746_update_account_summaries_to_version_2.rb b/db/migrate/20211213040746_update_account_summaries_to_version_2.rb new file mode 100644 index 000000000..0d1f092ec --- /dev/null +++ b/db/migrate/20211213040746_update_account_summaries_to_version_2.rb @@ -0,0 +1,24 @@ +class UpdateAccountSummariesToVersion2 < ActiveRecord::Migration[6.1] + def up + reapplication_follow_recommendations_v2 do + drop_view :account_summaries, materialized: true + create_view :account_summaries, version: 2, materialized: { no_data: true } + safety_assured { add_index :account_summaries, :account_id, unique: true } + end + end + + def down + reapplication_follow_recommendations_v2 do + drop_view :account_summaries, materialized: true + create_view :account_summaries, version: 1, materialized: { no_data: true } + safety_assured { add_index :account_summaries, :account_id, unique: true } + end + end + + def reapplication_follow_recommendations_v2 + drop_view :follow_recommendations, materialized: true + yield + create_view :follow_recommendations, version: 2, materialized: { no_data: true } + safety_assured { add_index :follow_recommendations, :account_id, unique: true } + end +end diff --git a/db/schema.rb b/db/schema.rb index 54a46730c..a1d169b23 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_11_26_000907) do +ActiveRecord::Schema.define(version: 2021_12_13_040746) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1129,7 +1129,7 @@ ActiveRecord::Schema.define(version: 2021_11_26_000907) do statuses.language, statuses.sensitive FROM statuses - WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL)) + WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL) AND (statuses.reblog_of_id IS NULL)) ORDER BY statuses.id DESC LIMIT 20) t0) WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false)) diff --git a/db/views/account_summaries_v02.sql b/db/views/account_summaries_v02.sql new file mode 100644 index 000000000..17f5605f8 --- /dev/null +++ b/db/views/account_summaries_v02.sql @@ -0,0 +1,23 @@ +SELECT + accounts.id AS account_id, + mode() WITHIN GROUP (ORDER BY language ASC) AS language, + mode() WITHIN GROUP (ORDER BY sensitive ASC) AS sensitive +FROM accounts +CROSS JOIN LATERAL ( + SELECT + statuses.account_id, + statuses.language, + statuses.sensitive + FROM statuses + WHERE statuses.account_id = accounts.id + AND statuses.deleted_at IS NULL + AND statuses.reblog_of_id IS NULL + ORDER BY statuses.id DESC + LIMIT 20 +) t0 +WHERE accounts.suspended_at IS NULL + AND accounts.silenced_at IS NULL + AND accounts.moved_to_account_id IS NULL + AND accounts.discoverable = 't' + AND accounts.locked = 'f' +GROUP BY accounts.id -- cgit From 8e84ebf0cb211c1d94145399b05c9f2ad0e4d4b0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 16 Jan 2022 13:23:50 +0100 Subject: Remove IP tracking columns from users table (#16409) --- .../api/v1/admin/accounts_controller.rb | 2 +- app/controllers/auth/sessions_controller.rb | 2 +- app/controllers/concerns/user_tracking_concern.rb | 6 +-- app/helpers/admin/dashboard_helper.rb | 10 ++-- app/models/account.rb | 1 - app/models/account_filter.rb | 2 +- app/models/user.rb | 58 ++++++---------------- app/models/user_ip.rb | 19 +++++++ app/serializers/rest/admin/account_serializer.rb | 13 +++-- app/serializers/rest/admin/ip_serializer.rb | 5 ++ app/views/admin/accounts/show.html.haml | 10 ++-- .../admin_mailer/new_pending_account.text.erb | 2 +- app/workers/scheduler/ip_cleanup_scheduler.rb | 2 +- config/initializers/devise.rb | 15 +++--- db/migrate/20210616214526_create_user_ips.rb | 5 ++ ...6214135_remove_current_sign_in_ip_from_users.rb | 12 +++++ db/schema.rb | 24 ++++++++- db/views/user_ips_v01.sql | 26 ++++++++++ spec/controllers/auth/sessions_controller_spec.rb | 2 +- 19 files changed, 141 insertions(+), 75 deletions(-) create mode 100644 app/models/user_ip.rb create mode 100644 app/serializers/rest/admin/ip_serializer.rb create mode 100644 db/migrate/20210616214526_create_user_ips.rb create mode 100644 db/post_migrate/20210616214135_remove_current_sign_in_ip_from_users.rb create mode 100644 db/views/user_ips_v01.sql (limited to 'app/workers/scheduler') diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index 63cc521ed..9b8f2fb05 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -94,7 +94,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController private def set_accounts - @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite, :ips]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_account diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 0184bfb52..3337a43c4 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -147,7 +147,7 @@ class Auth::SessionsController < Devise::SessionsController clear_attempt_from_session - user.update_sign_in!(request, new_sign_in: true) + user.update_sign_in!(new_sign_in: true) sign_in(user) flash.delete(:notice) diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index efda37fae..45f3aab0d 100644 --- a/app/controllers/concerns/user_tracking_concern.rb +++ b/app/controllers/concerns/user_tracking_concern.rb @@ -3,7 +3,7 @@ module UserTrackingConcern extend ActiveSupport::Concern - UPDATE_SIGN_IN_HOURS = 24 + UPDATE_SIGN_IN_FREQUENCY = 24.hours.freeze included do before_action :update_user_sign_in @@ -12,10 +12,10 @@ module UserTrackingConcern private def update_user_sign_in - current_user.update_sign_in!(request) if user_needs_sign_in_update? + current_user.update_sign_in! if user_needs_sign_in_update? end def user_needs_sign_in_update? - user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago) + user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_FREQUENCY.ago) end end diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb index 32aaf9f5e..d4a30b97e 100644 --- a/app/helpers/admin/dashboard_helper.rb +++ b/app/helpers/admin/dashboard_helper.rb @@ -2,17 +2,17 @@ module Admin::DashboardHelper def relevant_account_ip(account, ip_query) - default_ip = [account.user_current_sign_in_ip || account.user_sign_up_ip] + ips = account.user.ips.to_a matched_ip = begin ip_query_addr = IPAddr.new(ip_query) - account.user.recent_ips.find { |(_, ip)| ip_query_addr.include?(ip) } || default_ip + ips.find { |ip| ip_query_addr.include?(ip.ip) } || ips.first rescue IPAddr::Error - default_ip - end.last + ips.first + end if matched_ip - link_to matched_ip, admin_accounts_path(ip: matched_ip) + link_to matched_ip.ip, admin_accounts_path(ip: matched_ip.ip) else '-' end diff --git a/app/models/account.rb b/app/models/account.rb index 238ea1d65..c459125c7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -123,7 +123,6 @@ class Account < ApplicationRecord delegate :email, :unconfirmed_email, - :current_sign_in_ip, :current_sign_in_at, :created_at, :sign_up_ip, diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index defd531ac..dcb174122 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -21,7 +21,7 @@ class AccountFilter end def results - scope = Account.includes(:account_stat, user: [:session_activations, :invite_request]).without_instance_actor.reorder(nil) + scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil) params.each do |key, value| scope.merge!(scope_for(key, value.to_s.strip)) if value.present? diff --git a/app/models/user.rb b/app/models/user.rb index 374b82d05..49dcb8156 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,8 +14,6 @@ # sign_in_count :integer default(0), not null # current_sign_in_at :datetime # last_sign_in_at :datetime -# current_sign_in_ip :inet -# last_sign_in_ip :inet # admin :boolean default(FALSE), not null # confirmation_token :string # confirmed_at :datetime @@ -81,6 +79,7 @@ class User < ApplicationRecord has_many :invites, inverse_of: :user has_many :markers, inverse_of: :user, dependent: :destroy has_many :webauthn_credentials, dependent: :destroy + has_many :ips, class_name: 'UserIp', inverse_of: :user has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text } @@ -107,7 +106,7 @@ class User < ApplicationRecord scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) } scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } - scope :matches_ip, ->(value) { where('current_sign_in_ip <<= ?', value).or(where('users.sign_up_ip <<= ?', value)).or(where('users.last_sign_in_ip <<= ?', value)).or(where(id: SessionActivation.select(:user_id).where('ip <<= ?', value))) } + scope :matches_ip, ->(value) { left_joins(:ips).where('user_ips.ip <<= ?', value) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } before_validation :sanitize_languages @@ -174,15 +173,11 @@ class User < ApplicationRecord prepare_new_user! if new_user && approved? end - def update_sign_in!(request, new_sign_in: false) + def update_sign_in!(new_sign_in: false) old_current, new_current = current_sign_in_at, Time.now.utc self.last_sign_in_at = old_current || new_current self.current_sign_in_at = new_current - old_current, new_current = current_sign_in_ip, request.remote_ip - self.last_sign_in_ip = old_current || new_current - self.current_sign_in_ip = new_current - if new_sign_in self.sign_in_count ||= 0 self.sign_in_count += 1 @@ -201,7 +196,7 @@ class User < ApplicationRecord end def suspicious_sign_in?(ip) - !otp_required_for_login? && !skip_sign_in_token? && current_sign_in_at.present? && !recent_ip?(ip) + !otp_required_for_login? && !skip_sign_in_token? && current_sign_in_at.present? && !ips.where(ip: ip).exists? end def functional? @@ -277,31 +272,28 @@ class User < ApplicationRecord @shows_application ||= settings.show_application end - # rubocop:disable Naming/MethodParameterName - def token_for_app(a) - return nil if a.nil? || a.owner != self - Doorkeeper::AccessToken.find_or_create_by(application_id: a.id, resource_owner_id: id) do |t| - t.scopes = a.scopes - t.expires_in = Doorkeeper.configuration.access_token_expires_in + def token_for_app(app) + return nil if app.nil? || app.owner != self + + Doorkeeper::AccessToken.find_or_create_by(application_id: app.id, resource_owner_id: id) do |t| + t.scopes = app.scopes + t.expires_in = Doorkeeper.configuration.access_token_expires_in t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled? end end - # rubocop:enable Naming/MethodParameterName def activate_session(request) - session_activations.activate(session_id: SecureRandom.hex, - user_agent: request.user_agent, - ip: request.remote_ip).session_id + session_activations.activate( + session_id: SecureRandom.hex, + user_agent: request.user_agent, + ip: request.remote_ip + ).session_id end def clear_other_sessions(id) session_activations.exclusive(id) end - def session_active?(id) - session_activations.active? id - end - def web_push_subscription(session) session.web_push_subscription.nil? ? nil : session.web_push_subscription end @@ -364,22 +356,6 @@ class User < ApplicationRecord setting_display_media == 'hide_all' end - def recent_ips - @recent_ips ||= begin - arr = [] - - session_activations.each do |session_activation| - arr << [session_activation.updated_at, session_activation.ip] - end - - arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present? - arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present? - arr << [created_at, sign_up_ip] if sign_up_ip.present? - - arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse! - end - end - def sign_in_token_expired? sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago end @@ -410,10 +386,6 @@ class User < ApplicationRecord private - def recent_ip?(ip) - recent_ips.any? { |(_, recent_ip)| recent_ip == ip } - end - def send_pending_devise_notifications pending_devise_notifications.each do |notification, args, kwargs| render_and_send_devise_message(notification, *args, **kwargs) diff --git a/app/models/user_ip.rb b/app/models/user_ip.rb new file mode 100644 index 000000000..a8e802e13 --- /dev/null +++ b/app/models/user_ip.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: user_ips +# +# user_id :bigint(8) primary key +# ip :inet +# used_at :datetime +# + +class UserIp < ApplicationRecord + self.primary_key = :user_id + + belongs_to :user, foreign_key: :user_id + + def readonly? + true + end +end diff --git a/app/serializers/rest/admin/account_serializer.rb b/app/serializers/rest/admin/account_serializer.rb index f579d3302..3480e8c5a 100644 --- a/app/serializers/rest/admin/account_serializer.rb +++ b/app/serializers/rest/admin/account_serializer.rb @@ -9,6 +9,7 @@ class REST::Admin::AccountSerializer < ActiveModel::Serializer attribute :created_by_application_id, if: :created_by_application? attribute :invited_by_account_id, if: :invited? + has_many :ips, serializer: REST::Admin::IpSerializer has_one :account, serializer: REST::AccountSerializer def id @@ -19,10 +20,6 @@ class REST::Admin::AccountSerializer < ActiveModel::Serializer object.user_email end - def ip - object.user_current_sign_in_ip.to_s.presence - end - def role object.user_role end @@ -74,4 +71,12 @@ class REST::Admin::AccountSerializer < ActiveModel::Serializer def created_by_application? object.user&.created_by_application_id&.present? end + + def ips + object.user&.ips + end + + def ip + ips&.first + end end diff --git a/app/serializers/rest/admin/ip_serializer.rb b/app/serializers/rest/admin/ip_serializer.rb new file mode 100644 index 000000000..d11699dc4 --- /dev/null +++ b/app/serializers/rest/admin/ip_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::Admin::IpSerializer < ActiveModel::Serializer + attributes :ip, :used_at +end diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 64cfc9a77..3867d1b19 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -156,12 +156,14 @@ %time.formatted{ datetime: @account.created_at.iso8601, title: l(@account.created_at) }= l @account.created_at %td - - @account.user.recent_ips.each_with_index do |(_, ip), i| + - recent_ips = @account.user.ips.order(used_at: :desc).to_a + + - recent_ips.each_with_index do |recent_ip, i| %tr - if i.zero? - %th{ rowspan: @account.user.recent_ips.size }= t('admin.accounts.most_recent_ip') - %td= ip - %td= table_link_to 'search', t('admin.accounts.search_same_ip'), admin_accounts_path(ip: ip) + %th{ rowspan: recent_ips.size }= t('admin.accounts.most_recent_ip') + %td= recent_ip.ip + %td= table_link_to 'search', t('admin.accounts.search_same_ip'), admin_accounts_path(ip: recent_ip.ip) %tr %th= t('admin.accounts.most_recent_activity') diff --git a/app/views/admin_mailer/new_pending_account.text.erb b/app/views/admin_mailer/new_pending_account.text.erb index bcc251819..a8a2a35fa 100644 --- a/app/views/admin_mailer/new_pending_account.text.erb +++ b/app/views/admin_mailer/new_pending_account.text.erb @@ -3,7 +3,7 @@ <%= raw t('admin_mailer.new_pending_account.body') %> <%= @account.user_email %> (@<%= @account.username %>) -<%= @account.user_current_sign_in_ip %> +<%= @account.user_sign_up_ip %> <% if @account.user&.invite_request&.text.present? %> <%= quote_wrap(@account.user&.invite_request&.text) %> diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb index 918c10ac9..adc99c605 100644 --- a/app/workers/scheduler/ip_cleanup_scheduler.rb +++ b/app/workers/scheduler/ip_cleanup_scheduler.rb @@ -16,7 +16,7 @@ class Scheduler::IpCleanupScheduler def clean_ip_columns! SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all - User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil) + User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(sign_up_ip: nil) LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 5232e6cfd..b434c68fa 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,11 +1,8 @@ require 'devise/strategies/authenticatable' Warden::Manager.after_set_user except: :fetch do |user, warden| - if user.session_active?(warden.cookies.signed['_session_id'] || warden.raw_session['auth_id']) - session_id = warden.cookies.signed['_session_id'] || warden.raw_session['auth_id'] - else - session_id = user.activate_session(warden.request) - end + session_id = warden.cookies.signed['_session_id'] || warden.raw_session['auth_id'] + session_id = user.activate_session(warden.request) unless user.session_activations.active?(session_id) warden.cookies.signed['_session_id'] = { value: session_id, @@ -17,9 +14,13 @@ Warden::Manager.after_set_user except: :fetch do |user, warden| end Warden::Manager.after_fetch do |user, warden| - if user.session_active?(warden.cookies.signed['_session_id'] || warden.raw_session['auth_id']) + session_id = warden.cookies.signed['_session_id'] || warden.raw_session['auth_id'] + + if session_id && (session = user.session_activations.find_by(session_id: session_id)) + session.update(ip: warden.request.remote_ip) if session.ip != warden.request.remote_ip + warden.cookies.signed['_session_id'] = { - value: warden.cookies.signed['_session_id'] || warden.raw_session['auth_id'], + value: session_id, expires: 1.year.from_now, httponly: true, secure: (Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true'), diff --git a/db/migrate/20210616214526_create_user_ips.rb b/db/migrate/20210616214526_create_user_ips.rb new file mode 100644 index 000000000..68e81a9d8 --- /dev/null +++ b/db/migrate/20210616214526_create_user_ips.rb @@ -0,0 +1,5 @@ +class CreateUserIps < ActiveRecord::Migration[6.1] + def change + create_view :user_ips + end +end diff --git a/db/post_migrate/20210616214135_remove_current_sign_in_ip_from_users.rb b/db/post_migrate/20210616214135_remove_current_sign_in_ip_from_users.rb new file mode 100644 index 000000000..b53b247f2 --- /dev/null +++ b/db/post_migrate/20210616214135_remove_current_sign_in_ip_from_users.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class RemoveCurrentSignInIpFromUsers < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured do + remove_column :users, :current_sign_in_ip, :inet + remove_column :users, :last_sign_in_ip, :inet + end + end +end diff --git a/db/schema.rb b/db/schema.rb index a1d169b23..d1446c652 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -923,8 +923,6 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do t.integer "sign_in_count", default: 0, null: false t.datetime "current_sign_in_at" t.datetime "last_sign_in_at" - t.inet "current_sign_in_ip" - t.inet "last_sign_in_ip" t.boolean "admin", default: false, null: false t.string "confirmation_token" t.datetime "confirmed_at" @@ -1120,6 +1118,28 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do SQL add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true + create_view "user_ips", sql_definition: <<-SQL + SELECT t0.user_id, + t0.ip, + max(t0.used_at) AS used_at + FROM ( SELECT users.id AS user_id, + users.sign_up_ip AS ip, + users.created_at AS used_at + FROM users + WHERE (users.sign_up_ip IS NOT NULL) + UNION ALL + SELECT session_activations.user_id, + session_activations.ip, + session_activations.updated_at + FROM session_activations + UNION ALL + SELECT login_activities.user_id, + login_activities.ip, + login_activities.created_at + FROM login_activities + WHERE (login_activities.success = true)) t0 + GROUP BY t0.user_id, t0.ip; + SQL create_view "account_summaries", materialized: true, sql_definition: <<-SQL SELECT accounts.id AS account_id, mode() WITHIN GROUP (ORDER BY t0.language) AS language, diff --git a/db/views/user_ips_v01.sql b/db/views/user_ips_v01.sql new file mode 100644 index 000000000..50a8201cd --- /dev/null +++ b/db/views/user_ips_v01.sql @@ -0,0 +1,26 @@ +SELECT + user_id, + ip, + max(used_at) AS used_at +FROM ( + SELECT + id AS user_id, + sign_up_ip AS ip, + created_at AS used_at + FROM users + WHERE sign_up_ip IS NOT NULL + UNION ALL + SELECT + user_id, + ip, + updated_at + FROM session_activations + UNION ALL + SELECT + user_id, + ip, + created_at + FROM login_activities + WHERE success = 't' +) AS t0 +GROUP BY user_id, ip diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index f718f5dd9..2368cc2bf 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -400,7 +400,7 @@ RSpec.describe Auth::SessionsController, type: :controller do end context 'when 2FA is disabled and IP is unfamiliar' do - let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago, current_sign_in_ip: '0.0.0.0') } + let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago) } before do request.remote_ip = '10.10.10.10' -- cgit From 14f436c457560862fafabd753eb314c8b8a8e674 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 17 Jan 2022 09:41:33 +0100 Subject: Add notifications for statuses deleted by moderators (#17204) --- .../admin/account_moderation_notes_controller.rb | 2 +- app/controllers/admin/accounts_controller.rb | 2 +- app/controllers/admin/report_notes_controller.rb | 23 +- .../admin/reported_statuses_controller.rb | 44 --- app/controllers/admin/reports_controller.rb | 6 +- app/controllers/admin/statuses_controller.rb | 66 ++--- .../api/v1/admin/account_actions_controller.rb | 4 +- .../api/v1/admin/accounts_controller.rb | 6 +- .../api/v1/admin/dimensions_controller.rb | 1 + .../api/v1/admin/measures_controller.rb | 1 + app/controllers/api/v1/admin/reports_controller.rb | 16 +- .../api/v1/admin/retention_controller.rb | 1 + .../api/v1/admin/trends/tags_controller.rb | 3 + app/helpers/admin/filter_helper.rb | 1 + .../components/admin/ReportReasonSelector.js | 159 ++++++++++ .../mastodon/components/status_action_bar.js | 2 +- .../features/status/components/action_bar.js | 2 +- app/javascript/styles/mailer.scss | 4 + app/javascript/styles/mastodon/admin.scss | 328 ++++++++++++++++++++- app/javascript/styles/mastodon/polls.scss | 15 + .../metrics/measure/resolved_reports_measure.rb | 7 +- app/mailers/user_mailer.rb | 4 +- app/models/account_warning.rb | 22 +- app/models/admin/account_action.rb | 28 +- app/models/admin/status_batch_action.rb | 92 ++++++ app/models/admin/status_filter.rb | 41 +++ app/models/concerns/account_associations.rb | 2 +- app/models/form/status_batch.rb | 45 --- app/models/report.rb | 66 +++-- app/models/report_filter.rb | 2 +- app/serializers/rest/admin/report_serializer.rb | 7 +- app/services/remove_status_service.rb | 9 +- app/views/admin/action_logs/index.html.haml | 2 +- .../admin/report_notes/_report_note.html.haml | 23 +- app/views/admin/reports/_action_log.html.haml | 6 - app/views/admin/reports/_status.html.haml | 3 + app/views/admin/reports/show.html.haml | 274 +++++++++++------ app/views/admin/statuses/index.html.haml | 33 ++- app/views/admin/statuses/show.html.haml | 27 -- app/views/notification_mailer/_status.text.erb | 8 +- app/views/user_mailer/warning.html.haml | 16 +- app/views/user_mailer/warning.text.erb | 17 +- app/workers/scheduler/user_cleanup_scheduler.rb | 9 + config/locales/en.yml | 55 +++- config/routes.rb | 12 +- .../20211231080958_add_category_to_reports.rb | 21 ++ ...0115125126_add_report_id_to_account_warnings.rb | 6 + .../20220115125341_fix_account_warning_actions.rb | 21 ++ ...20116202951_add_deleted_at_index_on_statuses.rb | 7 + ...20109213908_remove_action_taken_from_reports.rb | 9 + db/schema.rb | 10 +- .../admin/report_notes_controller_spec.rb | 8 +- .../admin/reported_statuses_controller_spec.rb | 59 ---- spec/controllers/admin/reports_controller_spec.rb | 22 +- spec/controllers/admin/statuses_controller_spec.rb | 69 ++--- spec/fabricators/report_fabricator.rb | 6 +- spec/mailers/previews/user_mailer_preview.rb | 2 +- spec/models/form/status_batch_spec.rb | 52 ---- spec/models/report_spec.rb | 16 +- 59 files changed, 1213 insertions(+), 591 deletions(-) delete mode 100644 app/controllers/admin/reported_statuses_controller.rb create mode 100644 app/javascript/mastodon/components/admin/ReportReasonSelector.js create mode 100644 app/models/admin/status_batch_action.rb create mode 100644 app/models/admin/status_filter.rb delete mode 100644 app/models/form/status_batch.rb delete mode 100644 app/views/admin/reports/_action_log.html.haml delete mode 100644 app/views/admin/statuses/show.html.haml create mode 100644 db/migrate/20211231080958_add_category_to_reports.rb create mode 100644 db/migrate/20220115125126_add_report_id_to_account_warnings.rb create mode 100644 db/migrate/20220115125341_fix_account_warning_actions.rb create mode 100644 db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb create mode 100644 db/post_migrate/20220109213908_remove_action_taken_from_reports.rb delete mode 100644 spec/controllers/admin/reported_statuses_controller_spec.rb delete mode 100644 spec/models/form/status_batch_spec.rb (limited to 'app/workers/scheduler') diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index 44f6e34f8..4f36f33f4 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -14,7 +14,7 @@ module Admin else @account = @account_moderation_note.target_account @moderation_notes = @account.targeted_moderation_notes.latest - @warnings = @account.targeted_account_warnings.latest.custom + @warnings = @account.strikes.custom.latest render template: 'admin/accounts/show' end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 0786985fa..e7f56e243 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -28,7 +28,7 @@ module Admin @deletion_request = @account.deletion_request @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @moderation_notes = @account.targeted_moderation_notes.latest - @warnings = @account.targeted_account_warnings.latest.custom + @warnings = @account.strikes.custom.latest @domain_block = DomainBlock.rule_for(@account.domain) end diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index b816c5b5d..3fd815b60 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -14,20 +14,17 @@ module Admin if params[:create_and_resolve] @report.resolve!(current_account) log_action :resolve, @report - - redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg') - return - end - - if params[:create_and_unresolve] + elsif params[:create_and_unresolve] @report.unresolve! log_action :reopen, @report end - redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg') + redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg') else - @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at) - @form = Form::StatusBatch.new + @report_notes = @report.notes.includes(:account).order(id: :desc) + @action_logs = @report.history.includes(:target) + @form = Admin::StatusBatchAction.new + @statuses = @report.statuses.with_includes render template: 'admin/reports/show' end @@ -41,6 +38,14 @@ module Admin private + def after_create_redirect_path + if params[:create_and_resolve] + admin_reports_path + else + admin_report_path(@report) + end + end + def resource_params params.require(:report_note).permit( :content, diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb deleted file mode 100644 index 3ba9f5df2..000000000 --- a/app/controllers/admin/reported_statuses_controller.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Admin - class ReportedStatusesController < BaseController - before_action :set_report - - def create - authorize :status, :update? - - @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button)) - flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save - - redirect_to admin_report_path(@report) - rescue ActionController::ParameterMissing - flash[:alert] = I18n.t('admin.statuses.no_status_selected') - - redirect_to admin_report_path(@report) - end - - private - - def status_params - params.require(:status).permit(:sensitive) - end - - def form_status_batch_params - params.require(:form_status_batch).permit(status_ids: []) - end - - def action_from_button - if params[:nsfw_on] - 'nsfw_on' - elsif params[:nsfw_off] - 'nsfw_off' - elsif params[:delete] - 'delete' - end - end - - def set_report - @report = Report.find(params[:report_id]) - end - end -end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 7c831b3d4..00d200d7c 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -13,8 +13,10 @@ module Admin authorize @report, :show? @report_note = @report.notes.new - @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at) - @form = Form::StatusBatch.new + @report_notes = @report.notes.includes(:account).order(id: :desc) + @action_logs = @report.history.includes(:target) + @form = Admin::StatusBatchAction.new + @statuses = @report.statuses.with_includes end def assign_to_self diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index b3fd4c424..8d039b281 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -2,71 +2,57 @@ module Admin class StatusesController < BaseController - helper_method :current_params - before_action :set_account + before_action :set_statuses PER_PAGE = 20 def index authorize :status, :index? - @statuses = @account.statuses.where(visibility: [:public, :unlisted]) - - if params[:media] - @statuses = @statuses.merge(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)).reorder('statuses.id desc') - end - - @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) - @form = Form::StatusBatch.new - end - - def show - authorize :status, :index? - - @statuses = @account.statuses.where(id: params[:id]) - authorize @statuses.first, :show? - - @form = Form::StatusBatch.new + @status_batch_action = Admin::StatusBatchAction.new end - def create - authorize :status, :update? - - @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button)) - flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save - - redirect_to admin_account_statuses_path(@account.id, current_params) + def batch + @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) + @status_batch_action.save! rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.statuses.no_status_selected') - - redirect_to admin_account_statuses_path(@account.id, current_params) + ensure + redirect_to after_create_redirect_path end private - def form_status_batch_params - params.require(:form_status_batch).permit(:action, status_ids: []) + def admin_status_batch_action_params + params.require(:admin_status_batch_action).permit(status_ids: []) + end + + def after_create_redirect_path + if @status_batch_action.report_id.present? + admin_report_path(@status_batch_action.report_id) + else + admin_account_statuses_path(params[:account_id], current_params) + end end def set_account @account = Account.find(params[:account_id]) end - def current_params - page = (params[:page] || 1).to_i + def set_statuses + @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE) + end - { - media: params[:media], - page: page > 1 && page, - }.select { |_, value| value.present? } + def filter_params + params.slice(*Admin::StatusFilter::KEYS).permit(*Admin::StatusFilter::KEYS) end def action_from_button - if params[:nsfw_on] - 'nsfw_on' - elsif params[:nsfw_off] - 'nsfw_off' + if params[:report] + 'report' + elsif params[:remove_from_report] + 'remove_from_report' elsif params[:delete] 'delete' end diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb index 29c9b7107..15af50822 100644 --- a/app/controllers/api/v1/admin/account_actions_controller.rb +++ b/app/controllers/api/v1/admin/account_actions_controller.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class Api::V1::Admin::AccountActionsController < Api::BaseController - before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' } + protect_from_forgery with: :exception + + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' } before_action :require_staff! before_action :set_account diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index 9b8f2fb05..65330b8c8 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true class Api::V1::Admin::AccountsController < Api::BaseController + protect_from_forgery with: :exception + include Authorization include AccountableConcern LIMIT = 100 - before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:accounts' }, only: [:index, :show] - before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }, except: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show] before_action :require_staff! before_action :set_accounts, only: :index before_action :set_account, except: :index diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb index 5e8f0f89f..b1f738990 100644 --- a/app/controllers/api/v1/admin/dimensions_controller.rb +++ b/app/controllers/api/v1/admin/dimensions_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Admin::DimensionsController < Api::BaseController protect_from_forgery with: :exception + before_action -> { authorize_if_got_token! :'admin:read' } before_action :require_staff! before_action :set_dimensions diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb index f28191753..d64c3cdf7 100644 --- a/app/controllers/api/v1/admin/measures_controller.rb +++ b/app/controllers/api/v1/admin/measures_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Admin::MeasuresController < Api::BaseController protect_from_forgery with: :exception + before_action -> { authorize_if_got_token! :'admin:read' } before_action :require_staff! before_action :set_measures diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index c8f4cd8d8..fbfd0ee12 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true class Api::V1::Admin::ReportsController < Api::BaseController + protect_from_forgery with: :exception + include Authorization include AccountableConcern LIMIT = 100 - before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:reports' }, only: [:index, :show] - before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:reports' }, except: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show] before_action :require_staff! before_action :set_reports, only: :index before_action :set_report, except: :index @@ -32,6 +34,12 @@ class Api::V1::Admin::ReportsController < Api::BaseController render json: @report, serializer: REST::Admin::ReportSerializer end + def update + authorize @report, :update? + @report.update!(report_params) + render json: @report, serializer: REST::Admin::ReportSerializer + end + def assign_to_self authorize @report, :update? @report.update!(assigned_account_id: current_account.id) @@ -74,6 +82,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController ReportFilter.new(filter_params).results end + def report_params + params.permit(:category, rule_ids: []) + end + def filter_params params.permit(*FILTER_PARAMS) end diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb index a8ff64f21..4af5a5c4d 100644 --- a/app/controllers/api/v1/admin/retention_controller.rb +++ b/app/controllers/api/v1/admin/retention_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Admin::RetentionController < Api::BaseController protect_from_forgery with: :exception + before_action -> { authorize_if_got_token! :'admin:read' } before_action :require_staff! before_action :set_cohorts diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb index 3653d1dd1..4815af31e 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Api::V1::Admin::Trends::TagsController < Api::BaseController + protect_from_forgery with: :exception + + before_action -> { authorize_if_got_token! :'admin:read' } before_action :require_staff! before_action :set_tags diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 5f69f176a..907529b37 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -13,6 +13,7 @@ module Admin::FilterHelper RelationshipFilter::KEYS, AnnouncementFilter::KEYS, Admin::ActionLogFilter::KEYS, + Admin::StatusFilter::KEYS, ].flatten.freeze def filter_link_to(text, link_to_params, link_class_params = link_to_params) diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.js b/app/javascript/mastodon/components/admin/ReportReasonSelector.js new file mode 100644 index 000000000..1f91d2517 --- /dev/null +++ b/app/javascript/mastodon/components/admin/ReportReasonSelector.js @@ -0,0 +1,159 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import api from 'mastodon/api'; +import { injectIntl, defineMessages } from 'react-intl'; +import classNames from 'classnames'; + +const messages = defineMessages({ + other: { id: 'report.categories.other', defaultMessage: 'Other' }, + spam: { id: 'report.categories.spam', defaultMessage: 'Spam' }, + violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' }, +}); + +class Category extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + selected: PropTypes.bool, + disabled: PropTypes.bool, + onSelect: PropTypes.func, + children: PropTypes.node, + }; + + handleClick = () => { + const { id, disabled, onSelect } = this.props; + + if (!disabled) { + onSelect(id); + } + }; + + render () { + const { id, text, disabled, selected, children } = this.props; + + return ( +
+ {selected && } + +
+ + {text} +
+ + {(selected && children) && ( +
+ {children} +
+ )} +
+ ); + } + +} + +class Rule extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + selected: PropTypes.bool, + disabled: PropTypes.bool, + onToggle: PropTypes.func, + }; + + handleClick = () => { + const { id, disabled, onToggle } = this.props; + + if (!disabled) { + onToggle(id); + } + }; + + render () { + const { id, text, disabled, selected } = this.props; + + return ( +
+ + {selected && } + {text} +
+ ); + } + +} + +export default @injectIntl +class ReportReasonSelector extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + category: PropTypes.string.isRequired, + rule_ids: PropTypes.arrayOf(PropTypes.string), + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + category: this.props.category, + rule_ids: this.props.rule_ids || [], + rules: [], + }; + + componentDidMount() { + api().get('/api/v1/instance').then(res => { + this.setState({ + rules: res.data.rules, + }); + }).catch(err => { + console.error(err); + }); + } + + _save = () => { + const { id, disabled } = this.props; + const { category, rule_ids } = this.state; + + if (disabled) { + return; + } + + api().put(`/api/v1/admin/reports/${id}`, { + category, + rule_ids, + }).catch(err => { + console.error(err); + }); + }; + + handleSelect = id => { + this.setState({ category: id }, () => this._save()); + }; + + handleToggle = id => { + const { rule_ids } = this.state; + + if (rule_ids.includes(id)) { + this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save()); + } else { + this.setState({ rule_ids: [...rule_ids, id] }, () => this._save()); + } + }; + + render () { + const { disabled, intl } = this.props; + const { rules, category, rule_ids } = this.state; + + return ( +
+ + + + {rules.map(rule => )} + +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index d125359e9..4e19cc0e4 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -291,7 +291,7 @@ class StatusActionBar extends ImmutablePureComponent { if (isStaff) { menu.push(null); menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); - menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); + menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); } } diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index e60119bc4..a15a4d567 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -245,7 +245,7 @@ class ActionBar extends React.PureComponent { if (isStaff) { menu.push(null); menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); - menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); + menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); } } diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss index 92c02e847..34852178e 100644 --- a/app/javascript/styles/mailer.scss +++ b/app/javascript/styles/mailer.scss @@ -533,6 +533,10 @@ ul { } } +ul.rules-list { + padding-top: 0; +} + @media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) { body { min-height: 1024px !important; diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index dbf8a6e7a..c20762fba 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -579,39 +579,44 @@ body, .log-entry { line-height: 20px; - padding: 15px 0; + padding: 15px; + padding-left: 15px * 2 + 40px; background: $ui-base-color; - border-bottom: 1px solid lighten($ui-base-color, 4%); + border-bottom: 1px solid darken($ui-base-color, 8%); + position: relative; + + &:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } &:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; border-bottom: 0; } + &:hover { + background: lighten($ui-base-color, 4%); + } + &__header { - display: flex; - justify-content: flex-start; - align-items: center; color: $darker-text-color; font-size: 14px; - padding: 0 10px; } &__avatar { - margin-right: 10px; + position: absolute; + left: 15px; + top: 15px; .avatar { - display: block; - margin: 0; - border-radius: 50%; + border-radius: 4px; width: 40px; height: 40px; } } - &__content { - max-width: calc(100% - 90px); - } - &__title { word-wrap: break-word; } @@ -627,6 +632,14 @@ body, text-decoration: none; font-weight: 500; } + + a { + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } } a.name-tag, @@ -655,8 +668,9 @@ a.inline-name-tag, a.name-tag, .name-tag { - display: flex; + display: inline-flex; align-items: center; + vertical-align: top; .avatar { display: block; @@ -1114,3 +1128,287 @@ a.sparkline { } } } + +.report-reason-selector { + border-radius: 4px; + background: $ui-base-color; + margin-bottom: 20px; + + &__category { + cursor: pointer; + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__label { + padding: 15px; + } + + &__rules { + margin-left: 30px; + } + } + + &__rule { + cursor: pointer; + padding: 15px; + } +} + +.report-header { + display: grid; + grid-gap: 15px; + grid-template-columns: minmax(0, 1fr) 300px; + + &__details { + &__item { + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 15px 0; + + &:last-child { + border-bottom: 0; + } + + &__header { + font-weight: 600; + padding: 4px 0; + } + } + + &--horizontal { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + + .report-header__details__item { + border-bottom: 0; + } + } + } +} + +.account-card { + background: $ui-base-color; + border-radius: 4px; + + &__header { + padding: 4px; + border-radius: 4px; + height: 128px; + + img { + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: cover; + background: darken($ui-base-color, 8%); + } + } + + &__title { + margin-top: -25px; + display: flex; + align-items: flex-end; + + &__avatar { + padding: 15px; + + img { + display: block; + margin: 0; + width: 56px; + height: 56px; + background: darken($ui-base-color, 8%); + border-radius: 8px; + } + } + + .display-name { + color: $darker-text-color; + padding-bottom: 15px; + font-size: 15px; + + bdi { + display: block; + color: $primary-text-color; + font-weight: 500; + } + } + } + + &__bio { + padding: 0 15px; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; + max-height: 18px * 2; + position: relative; + + &::after { + display: block; + content: ""; + width: 50px; + height: 18px; + position: absolute; + bottom: 0; + right: 15px; + background: linear-gradient(to left, $ui-base-color, transparent); + pointer-events: none; + } + } + + &__actions { + display: flex; + align-items: center; + padding-top: 10px; + + &__button { + flex: 0 0 auto; + padding: 0 15px; + } + } + + &__counters { + flex: 1 1 auto; + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + + &__item { + padding: 15px; + text-align: center; + color: $primary-text-color; + font-weight: 600; + font-size: 15px; + + small { + display: block; + color: $darker-text-color; + font-weight: 400; + font-size: 13px; + } + } + } +} + +.report-notes { + margin-bottom: 20px; + + &__item { + background: $ui-base-color; + position: relative; + padding: 15px; + padding-left: 15px * 2 + 40px; + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + &:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: 0; + } + + &:hover { + background-color: lighten($ui-base-color, 4%); + } + + &__avatar { + position: absolute; + left: 15px; + top: 15px; + border-radius: 4px; + width: 40px; + height: 40px; + } + + &__header { + color: $darker-text-color; + font-size: 15px; + line-height: 20px; + margin-bottom: 4px; + + .username a { + color: $primary-text-color; + font-weight: 500; + text-decoration: none; + margin-right: 5px; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + time { + margin-left: 5px; + vertical-align: baseline; + } + } + + &__content { + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + color: $primary-text-color; + + p { + margin-bottom: 20px; + white-space: pre-wrap; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + } + } + + &__actions { + position: absolute; + top: 15px; + right: 15px; + text-align: right; + } + } +} + +.report-actions { + border: 1px solid darken($ui-base-color, 8%); + + &__item { + display: flex; + align-items: center; + line-height: 18px; + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__button { + flex: 0 0 auto; + width: 100px; + padding: 15px; + padding-right: 0; + + .button { + display: block; + width: 100%; + } + } + + &__description { + padding: 15px; + font-size: 14px; + color: $dark-text-color; + } + } +} diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss index ad7088982..e33fc7983 100644 --- a/app/javascript/styles/mastodon/polls.scss +++ b/app/javascript/styles/mastodon/polls.scss @@ -143,6 +143,21 @@ &:active { outline: 0 !important; } + + &.disabled { + border-color: $dark-text-color; + + &.active { + background: $dark-text-color; + } + + &:active, + &:focus, + &:hover { + border-color: $dark-text-color; + border-width: 1px; + } + } } &__number { diff --git a/app/lib/admin/metrics/measure/resolved_reports_measure.rb b/app/lib/admin/metrics/measure/resolved_reports_measure.rb index 0dcecbbad..00cb24f7e 100644 --- a/app/lib/admin/metrics/measure/resolved_reports_measure.rb +++ b/app/lib/admin/metrics/measure/resolved_reports_measure.rb @@ -6,11 +6,11 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure: end def total - Report.resolved.where(updated_at: time_period).count + Report.resolved.where(action_taken_at: time_period).count end def previous_total - Report.resolved.where(updated_at: previous_time_period).count + Report.resolved.where(action_taken_at: previous_time_period).count end def data @@ -19,8 +19,7 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure: WITH resolved_reports AS ( SELECT reports.id FROM reports - WHERE action_taken - AND date_trunc('day', reports.updated_at)::date = axis.period + WHERE date_trunc('day', reports.action_taken_at)::date = axis.period ) SELECT count(*) FROM resolved_reports ) AS value diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 68d1c4507..5221a4892 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -160,11 +160,11 @@ class UserMailer < Devise::Mailer end end - def warning(user, warning, status_ids = nil) + def warning(user, warning) @resource = user @warning = warning @instance = Rails.configuration.x.local_domain - @statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array) + @statuses = @warning.statuses.includes(:account, :preloadable_poll, :media_attachments, active_mentions: [:account]) I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb index 5efc924d5..fc0d988fd 100644 --- a/app/models/account_warning.rb +++ b/app/models/account_warning.rb @@ -10,14 +10,30 @@ # text :text default(""), not null # created_at :datetime not null # updated_at :datetime not null +# report_id :bigint(8) +# status_ids :string is an Array # class AccountWarning < ApplicationRecord - enum action: %i(none disable sensitive silence suspend), _suffix: :action + enum action: { + none: 0, + disable: 1_000, + delete_statuses: 1_500, + sensitive: 2_000, + silence: 3_000, + suspend: 4_000, + }, _suffix: :action belongs_to :account, inverse_of: :account_warnings - belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings + belongs_to :target_account, class_name: 'Account', inverse_of: :strikes + belongs_to :report, optional: true - scope :latest, -> { order(created_at: :desc) } + has_one :appeal, dependent: :destroy + + scope :latest, -> { order(id: :desc) } scope :custom, -> { where.not(text: '') } + + def statuses + Status.with_discarded.where(id: status_ids || []) + end end diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index bf222391f..d3be4be3f 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -33,7 +33,7 @@ class Admin::AccountAction def save! ApplicationRecord.transaction do process_action! - process_warning! + process_strike! end process_email! @@ -74,20 +74,14 @@ class Admin::AccountAction end end - def process_warning! - return unless warnable? - - authorize(target_account, :warn?) - - @warning = AccountWarning.create!(target_account: target_account, - account: current_account, - action: type, - text: text_for_warning) - - # A log entry is only interesting if the warning contains - # custom text from someone. Otherwise it's just noise. - - log_action(:create, warning) if warning.text.present? + def process_strike! + @warning = target_account.strikes.create!( + account: current_account, + report: report, + action: type, + text: text_for_warning, + status_ids: status_ids + ) end def process_reports! @@ -143,7 +137,7 @@ class Admin::AccountAction end def process_email! - UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable? + UserMailer.warning(target_account.user, warning).deliver_later! if warnable? end def warnable? @@ -151,7 +145,7 @@ class Admin::AccountAction end def status_ids - report.status_ids if report && include_statuses + report.status_ids if with_report? && include_statuses end def reports diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb new file mode 100644 index 000000000..319deff98 --- /dev/null +++ b/app/models/admin/status_batch_action.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class Admin::StatusBatchAction + include ActiveModel::Model + include AccountableConcern + include Authorization + + attr_accessor :current_account, :type, + :status_ids, :report_id + + def save! + process_action! + end + + private + + def statuses + Status.with_discarded.where(id: status_ids) + end + + def process_action! + return if status_ids.empty? + + case type + when 'delete' + handle_delete! + when 'report' + handle_report! + when 'remove_from_report' + handle_remove_from_report! + end + end + + def handle_delete! + statuses.each { |status| authorize(status, :destroy?) } + + ApplicationRecord.transaction do + statuses.each do |status| + status.discard + log_action(:destroy, status) + end + + if with_report? + report.resolve!(current_account) + log_action(:resolve, report) + end + + @warning = target_account.strikes.create!( + action: :delete_statuses, + account: current_account, + report: report, + status_ids: status_ids + ) + + statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local? + end + + UserMailer.warning(target_account.user, @warning).deliver_later! if target_account.local? + RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, preserve: target_account.local?, immediate: !target_account.local?] } + end + + def handle_report! + @report = Report.new(report_params) unless with_report? + @report.status_ids = (@report.status_ids + status_ids.map(&:to_i)).uniq + @report.save! + + @report_id = @report.id + end + + def handle_remove_from_report! + return unless with_report? + + report.status_ids -= status_ids.map(&:to_i) + report.save! + end + + def report + @report ||= Report.find(report_id) if report_id.present? + end + + def with_report? + !report.nil? + end + + def target_account + @target_account ||= statuses.first.account + end + + def report_params + { account: current_account, target_account: target_account } + end +end diff --git a/app/models/admin/status_filter.rb b/app/models/admin/status_filter.rb new file mode 100644 index 000000000..ce5bb5f46 --- /dev/null +++ b/app/models/admin/status_filter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Admin::StatusFilter + KEYS = %i( + media + id + report_id + ).freeze + + attr_reader :params + + def initialize(account, params) + @account = account + @params = params + end + + def results + scope = @account.statuses.where(visibility: [:public, :unlisted]) + + params.each do |key, value| + next if %w(page report_id).include?(key.to_s) + + 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 'media' + Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) + when 'id' + Status.where(id: value) + else + raise "Unknown filter: #{key}" + end + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index f9e7a3bea..bbe269e8f 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -42,7 +42,7 @@ module AccountAssociations has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account has_many :account_warnings, dependent: :destroy, inverse_of: :account - has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account + has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account # Lists (that the account is on, not owned by the account) has_many :list_accounts, inverse_of: :account, dependent: :destroy diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb deleted file mode 100644 index c4943a7ea..000000000 --- a/app/models/form/status_batch.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -class Form::StatusBatch - include ActiveModel::Model - include AccountableConcern - - attr_accessor :status_ids, :action, :current_account - - def save - case action - when 'nsfw_on', 'nsfw_off' - change_sensitive(action == 'nsfw_on') - when 'delete' - delete_statuses - end - end - - private - - def change_sensitive(sensitive) - media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id) - - ApplicationRecord.transaction do - Status.where(id: media_attached_status_ids).reorder(nil).find_each do |status| - status.update!(sensitive: sensitive) - log_action :update, status - end - end - - true - rescue ActiveRecord::RecordInvalid - false - end - - def delete_statuses - Status.where(id: status_ids).reorder(nil).find_each do |status| - status.discard - RemovalWorker.perform_async(status.id, immediate: true) - Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) - log_action :destroy, status - end - - true - end -end diff --git a/app/models/report.rb b/app/models/report.rb index ef41547d9..ceb15133b 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -6,7 +6,6 @@ # id :bigint(8) not null, primary key # status_ids :bigint(8) default([]), not null, is an Array # comment :text default(""), not null -# action_taken :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint(8) not null @@ -15,9 +14,14 @@ # assigned_account_id :bigint(8) # uri :string # forwarded :boolean +# category :integer default("other"), not null +# action_taken_at :datetime +# rule_ids :bigint(8) is an Array # class Report < ApplicationRecord + self.ignored_columns = %w(action_taken) + include Paginable include RateLimitable @@ -30,11 +34,17 @@ class Report < ApplicationRecord has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy - scope :unresolved, -> { where(action_taken: false) } - scope :resolved, -> { where(action_taken: true) } + scope :unresolved, -> { where(action_taken_at: nil) } + scope :resolved, -> { where.not(action_taken_at: nil) } scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) } - validates :comment, length: { maximum: 1000 } + validates :comment, length: { maximum: 1_000 } + + enum category: { + other: 0, + spam: 1_000, + violation: 2_000, + } def local? false # Force uri_for to use uri attribute @@ -47,13 +57,17 @@ class Report < ApplicationRecord end def statuses - Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions) + Status.with_discarded.where(id: status_ids) end def media_attachments MediaAttachment.where(status_id: status_ids) end + def rules + Rule.with_discarded.where(id: rule_ids) + end + def assign_to_self!(current_account) update!(assigned_account_id: current_account.id) end @@ -63,22 +77,19 @@ class Report < ApplicationRecord end def resolve!(acting_account) - if account_id == -99 && target_account.trust_level == Account::TRUST_LEVELS[:untrusted] - # This is an automated report and it is being dismissed, so it's - # a false positive, in which case update the account's trust level - # to prevent further spam checks - - target_account.update(trust_level: Account::TRUST_LEVELS[:trusted]) - end - - RemovalWorker.push_bulk(Status.with_discarded.discarded.where(id: status_ids).pluck(:id)) { |status_id| [status_id, { immediate: true }] } - update!(action_taken: true, action_taken_by_account_id: acting_account.id) + update!(action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id) end def unresolve! - update!(action_taken: false, action_taken_by_account_id: nil) + update!(action_taken_at: nil, action_taken_by_account_id: nil) + end + + def action_taken? + action_taken_at.present? end + alias action_taken action_taken? + def unresolved? !action_taken? end @@ -88,29 +99,24 @@ class Report < ApplicationRecord end def history - time_range = created_at..updated_at - - sql = [ + subquery = [ Admin::ActionLog.where( target_type: 'Report', - target_id: id, - created_at: time_range - ).unscope(:order), + target_id: id + ).unscope(:order).arel, Admin::ActionLog.where( target_type: 'Account', - target_id: target_account_id, - created_at: time_range - ).unscope(:order), + target_id: target_account_id + ).unscope(:order).arel, Admin::ActionLog.where( target_type: 'Status', - target_id: status_ids, - created_at: time_range - ).unscope(:order), - ].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ') + target_id: status_ids + ).unscope(:order).arel, + ].reduce { |union, query| Arel::Nodes::UnionAll.new(union, query) } - Admin::ActionLog.from("(#{sql}) AS admin_action_logs") + Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table)) end def set_uri diff --git a/app/models/report_filter.rb b/app/models/report_filter.rb index a91a6baeb..dc444a552 100644 --- a/app/models/report_filter.rb +++ b/app/models/report_filter.rb @@ -19,7 +19,7 @@ class ReportFilter scope = Report.unresolved params.each do |key, value| - scope = scope.merge scope_for(key, value) + scope = scope.merge scope_for(key, value), rewhere: true end scope diff --git a/app/serializers/rest/admin/report_serializer.rb b/app/serializers/rest/admin/report_serializer.rb index 7a77132c0..74bc0c520 100644 --- a/app/serializers/rest/admin/report_serializer.rb +++ b/app/serializers/rest/admin/report_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::Admin::ReportSerializer < ActiveModel::Serializer - attributes :id, :action_taken, :comment, :created_at, :updated_at + attributes :id, :action_taken, :category, :comment, :created_at, :updated_at has_one :account, serializer: REST::Admin::AccountSerializer has_one :target_account, serializer: REST::Admin::AccountSerializer @@ -9,8 +9,13 @@ class REST::Admin::ReportSerializer < ActiveModel::Serializer has_one :action_taken_by_account, serializer: REST::Admin::AccountSerializer has_many :statuses, serializer: REST::StatusSerializer + has_many :rules, serializer: REST::RuleSerializer def id object.id.to_s end + + def statuses + object.statuses.with_includes + end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index f9c3dcf78..3535b503b 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -9,6 +9,7 @@ class RemoveStatusService < BaseService # @param [Hash] options # @option [Boolean] :redraft # @option [Boolean] :immediate + # @option [Boolean] :preserve # @option [Boolean] :original_removed def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @@ -43,7 +44,7 @@ class RemoveStatusService < BaseService remove_media end - @status.destroy! if @options[:immediate] || !@status.reported? + @status.destroy! if permanently? else raise Mastodon::RaceConditionError end @@ -135,11 +136,15 @@ class RemoveStatusService < BaseService end def remove_media - return if @options[:redraft] || (!@options[:immediate] && @status.reported?) + return if @options[:redraft] || !permanently? @status.media_attachments.destroy_all end + def permanently? + @options[:immediate] || !(@options[:preserve] || @status.reported?) + end + def lock_options { redis: Redis.current, key: "distribute:#{@status.id}", autorelease: 5.minutes.seconds } end diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml index f7f73150b..f611bfe9d 100644 --- a/app/views/admin/action_logs/index.html.haml +++ b/app/views/admin/action_logs/index.html.haml @@ -22,7 +22,7 @@ %div.muted-hint.center-text = t 'admin.action_logs.empty' - else - .announcements-list + .report-notes = render partial: 'action_log', collection: @action_logs = paginate @action_logs diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml index d34dc3d15..428b6cf59 100644 --- a/app/views/admin/report_notes/_report_note.html.haml +++ b/app/views/admin/report_notes/_report_note.html.haml @@ -1,7 +1,18 @@ -.speech-bubble - .speech-bubble__bubble +.report-notes__item + = image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar' + + .report-notes__item__header + %span.username + = link_to display_name(report_note.account), admin_account_path(report_note.account_id) + %time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) } + - if report_note.created_at.today? + = t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time)) + - else + = l report_note.created_at.to_date + + .report-notes__item__content = simple_format(h(report_note.content)) - .speech-bubble__owner - = admin_account_link_to report_note.account - %time.formatted{ datetime: report_note.created_at.iso8601 }= l report_note.created_at - = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note) + + - if can?(:destroy, report_note) + .report-notes__item__actions + = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete diff --git a/app/views/admin/reports/_action_log.html.haml b/app/views/admin/reports/_action_log.html.haml deleted file mode 100644 index 0f7d05867..000000000 --- a/app/views/admin/reports/_action_log.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -.speech-bubble.positive - .speech-bubble__bubble - = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')) - .speech-bubble__owner - = admin_account_link_to(action_log.account) - %time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml index ada6dd2bc..924b0e9c2 100644 --- a/app/views/admin/reports/_status.html.haml +++ b/app/views/admin/reports/_status.html.haml @@ -22,6 +22,9 @@ = react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } .detailed-status__meta + - if status.application + = status.application.name + · = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) - if status.discarded? diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml index b060c553f..4f513dd39 100644 --- a/app/views/admin/reports/show.html.haml +++ b/app/views/admin/reports/show.html.haml @@ -1,5 +1,6 @@ - content_for :header_tags do = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + = javascript_pack_tag 'public', async: true, crossorigin: 'anonymous' - content_for :page_title do = t('admin.reports.report', id: @report.id) @@ -10,122 +11,199 @@ - else = link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button' -.table-wrapper - %table.table.inline-table - %tbody - %tr - %th= t('admin.reports.reported_account') - %td= admin_account_link_to @report.target_account - %td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.target_account.targeted_reports.count), admin_reports_path(target_account_id: @report.target_account.id) - %td= table_link_to 'file', t('admin.reports.account.notes', count: @report.target_account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.target_account.id) - %tr - %th= t('admin.reports.reported_by') +.report-header + .report-header__card + .account-card + .account-card__header + = image_tag @report.target_account.header.url, alt: '' + .account-card__title + .account-card__title__avatar + = image_tag @report.target_account.avatar.url, alt: '' + .display-name + %bdi + %strong.emojify.p-name= display_name(@report.target_account, custom_emojify: true) + %span + = acct(@report.target_account) + = fa_icon('lock') if @report.target_account.locked? + - if @report.target_account.note.present? + .account-card__bio.emojify + = Formatter.instance.simplified_format(@report.target_account, custom_emojify: true) + .account-card__actions + .account-card__counters + .account-card__counters__item + = friendly_number_to_human @report.target_account.statuses_count + %small= t('accounts.posts', count: @report.target_account.statuses_count).downcase + .account-card__counters__item + = friendly_number_to_human @report.target_account.followers_count + %small= t('accounts.followers', count: @report.target_account.followers_count).downcase + .account-card__counters__item + = friendly_number_to_human @report.target_account.following_count + %small= t('accounts.following', count: @report.target_account.following_count).downcase + .account-card__actions__button + = link_to t('admin.reports.view_profile'), admin_account_path(@report.target_account_id), class: 'button' + .report-header__details.report-header__details--horizontal + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.accounts.joined') + .report-header__details__item__content + %time.time-ago{ datetime: @report.target_account.created_at.iso8601, title: l(@report.target_account.created_at) }= l @report.target_account.created_at + .report-header__details__item + .report-header__details__item__header + %strong= t('accounts.last_active') + .report-header__details__item__content + - if @report.target_account.last_status_at.present? + %time.time-ago{ datetime: @report.target_account.last_status_at.to_date.iso8601, title: l(@report.target_account.last_status_at.to_date) }= l @report.target_account.last_status_at + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.accounts.strikes') + .report-header__details__item__content + = @report.target_account.strikes.count + + .report-header__details + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.reports.created_at') + .report-header__details__item__content + %time.formatted{ datetime: @report.created_at.iso8601 } + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.reports.reported_by') + .report-header__details__item__content - if @report.account.instance_actor? - %td{ colspan: 3 }= site_hostname + = site_hostname - elsif @report.account.local? - %td= admin_account_link_to @report.account - %td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.account.targeted_reports.count), admin_reports_path(target_account_id: @report.account.id) - %td= table_link_to 'file', t('admin.reports.account.notes', count: @report.account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.account.id) + = admin_account_link_to @report.account + - else + = @report.account.domain + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.reports.status') + .report-header__details__item__content + - if @report.action_taken? + = t('admin.reports.resolved') - else - %td{ colspan: 3 }= @report.account.domain - %tr - %th= t('admin.reports.created_at') - %td{ colspan: 3 } - %time.formatted{ datetime: @report.created_at.iso8601 } - %tr - %th= t('admin.reports.updated_at') - %td{ colspan: 3 } - %time.formatted{ datetime: @report.updated_at.iso8601 } - %tr - %th= t('admin.reports.status') - %td - - if @report.action_taken? - = t('admin.reports.resolved') + = t('admin.reports.unresolved') + - unless @report.target_account.local? + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.reports.forwarded') + .report-header__details__item__content + - if @report.forwarded? + = t('simple_form.yes') - else - = t('admin.reports.unresolved') - %td{ colspan: 2 } - - if @report.action_taken? - = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put - - unless @report.target_account.local? - %tr - %th= t('admin.reports.forwarded') - %td{ colspan: 3 } - - if @report.forwarded.nil? - \- - - elsif @report.forwarded? - = t('simple_form.yes') - - else - = t('simple_form.no') - - if !@report.action_taken_by_account.nil? - %tr - %th= t('admin.reports.action_taken_by') - %td{ colspan: 3 } - = admin_account_link_to @report.action_taken_by_account - - else - %tr - %th= t('admin.reports.assigned') - %td - - if @report.assigned_account.nil? - \- - - else - = admin_account_link_to @report.assigned_account - %td - - if @report.assigned_account != current_user.account - = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post - %td - - if !@report.assigned_account.nil? - = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post + = t('simple_form.no') + - if !@report.action_taken_by_account.nil? + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.reports.action_taken_by') + .report-header__details__item__content + = admin_account_link_to @report.action_taken_by_account + - else + .report-header__details__item + .report-header__details__item__header + %strong= t('admin.reports.assigned') + .report-header__details__item__content + - if @report.assigned_account.nil? + = t 'admin.reports.no_one_assigned' + - else + = admin_account_link_to @report.assigned_account + — + - if @report.assigned_account != current_user.account + = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post + - elsif !@report.assigned_account.nil? + = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post %hr.spacer -%div.action-buttons - %div +%h3= t 'admin.reports.category' - - if @report.unresolved? - %div - - if @report.target_account.local? - = link_to t('admin.accounts.warn'), new_admin_account_action_path(@report.target_account_id, type: 'none', report_id: @report.id), class: 'button' - = link_to t('admin.accounts.disable'), new_admin_account_action_path(@report.target_account_id, type: 'disable', report_id: @report.id), class: 'button button--destructive' - = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive' - = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, type: 'suspend', report_id: @report.id), class: 'button button--destructive' +%p= t 'admin.reports.category_description_html' -%hr.spacer += react_admin_component :report_reason_selector, id: @report.id, category: @report.category, rule_ids: @report.rule_ids&.map(&:to_s), disabled: @report.action_taken? -.speech-bubble - .speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none')) - .speech-bubble__owner - - if @report.account.local? - = admin_account_link_to @report.account - - else - = @report.account.domain - %br/ - %time.formatted{ datetime: @report.created_at.iso8601 } +- if @report.comment.present? + %p= t('admin.reports.comment_description_html', name: content_tag(:strong, @report.account.username, class: 'username')) + + .report-notes__item + = image_tag @report.account.avatar.url, class: 'report-notes__item__avatar' + + .report-notes__item__header + %span.username + = link_to display_name(@report.account), admin_account_path(@report.account_id) + %time{ datetime: @report.created_at.iso8601, title: l(@report.created_at) } + - if @report.created_at.today? + = t('admin.report_notes.today_at', time: l(@report.created_at, format: :time)) + - else + = l @report.created_at.to_date + + .report-notes__item__content + = simple_format(h(@report.comment)) + +%hr.spacer/ -- unless @report.statuses.empty? +%h3= t 'admin.reports.statuses' + +%p + = t 'admin.reports.statuses_description_html' + — + = link_to safe_join([fa_icon('plus'), t('admin.reports.add_to_report')]), admin_account_statuses_path(@report.target_account_id, report_id: @report.id), class: 'table-action-link' + += form_for(@form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id)) do |f| + .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 + - if !@statuses.empty? && @report.unresolved? + = f.button safe_join([fa_icon('times'), t('admin.statuses.batch.remove_from_report')]), name: :remove_from_report, class: 'table-action-link', type: :submit + = f.button safe_join([fa_icon('trash'), t('admin.reports.delete_and_resolve')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + - else + .batch-table__body + - if @statuses.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } + +- if @report.unresolved? %hr.spacer/ - = form_for(@form, url: admin_report_reported_statuses_path(@report.id)) do |f| - .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('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - .batch-table__body - = render partial: 'admin/reports/status', collection: @report.statuses, locals: { f: f } + %p= t 'admin.reports.actions_description_html' + + .report-actions + .report-actions__item + .report-actions__item__button + = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive' + .report-actions__item__description + = t('admin.reports.actions.silence_description_html') + .report-actions__item + .report-actions__item__button + = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id, type: 'suspend'), class: 'button button--destructive' + .report-actions__item__description + = t('admin.reports.actions.suspend_description_html') + .report-actions__item + .report-actions__item__button + = link_to t('admin.accounts.custom'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id), class: 'button' + .report-actions__item__description + = t('admin.reports.actions.other_description_html') + +- unless @action_logs.empty? + %hr.spacer/ + + %h3= t 'admin.reports.action_log' + + .report-notes + = render @action_logs %hr.spacer/ -- @report_notes.each do |item| - - if item.is_a?(Admin::ActionLog) - = render partial: 'action_log', locals: { action_log: item } - - else - = render item +%h3= t 'admin.reports.notes.title' + +%p= t 'admin.reports.notes_description_html' + +.report-notes + = render @report_notes = simple_form_for @report_note, url: admin_report_notes_path do |f| - = render 'shared/error_messages', object: @report_note = f.input :report_id, as: :hidden .field-group diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml index c39ba9071..7e2114cc2 100644 --- a/app/views/admin/statuses/index.html.haml +++ b/app/views/admin/statuses/index.html.haml @@ -10,28 +10,37 @@ .filter-subset %strong= t('admin.statuses.media.title') %ul - %li= link_to t('admin.statuses.no_media'), admin_account_statuses_path(@account.id, current_params.merge(media: nil)), class: !params[:media] && 'selected' - %li= link_to t('admin.statuses.with_media'), admin_account_statuses_path(@account.id, current_params.merge(media: true)), class: params[:media] && 'selected' + %li= filter_link_to t('generic.all'), media: nil, id: nil + %li= filter_link_to t('admin.statuses.with_media'), media: '1' .back-link - = link_to admin_account_path(@account.id) do - = fa_icon 'chevron-left fw' - = t('admin.statuses.back_to_account') + - if params[:report_id] + = link_to admin_report_path(params[:report_id].to_i) do + = fa_icon 'chevron-left fw' + = t('admin.statuses.back_to_report') + - else + = link_to admin_account_path(@account.id) do + = fa_icon 'chevron-left fw' + = t('admin.statuses.back_to_account') %hr.spacer/ -= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f| - = hidden_field_tag :page, params[:page] - = hidden_field_tag :media, params[:media] += form_for(@status_batch_action, url: batch_admin_account_statuses_path(@account.id)) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - Admin::StatusFilter::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('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + - unless @statuses.empty? + = f.button safe_join([fa_icon('flag'), t('admin.statuses.batch.report')]), name: :report, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } .batch-table__body - = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } + - if @statuses.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } = paginate @statuses diff --git a/app/views/admin/statuses/show.html.haml b/app/views/admin/statuses/show.html.haml deleted file mode 100644 index e2470198d..000000000 --- a/app/views/admin/statuses/show.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- content_for :page_title do - = t('admin.statuses.title') - \- - = "@#{@account.acct}" - -.filters - .back-link - = link_to admin_account_path(@account.id) do - %i.fa.fa-chevron-left.fa-fw - = t('admin.statuses.back_to_account') - -%hr.spacer/ - -= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f| - = hidden_field_tag :page, params[:page] - = hidden_field_tag :media, params[:media] - - .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('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - .batch-table__body - = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb index 8999a1f8e..c43f32d9f 100644 --- a/app/views/notification_mailer/_status.text.erb +++ b/app/views/notification_mailer/_status.text.erb @@ -1,8 +1,8 @@ <% if status.spoiler_text? %> -<%= raw status.spoiler_text %> ----- - +> <%= raw word_wrap(status.spoiler_text, break_sequence: "\n> ") %> +> ---- +> <% end %> -<%= raw Formatter.instance.plaintext(status) %> +> <%= raw word_wrap(Formatter.instance.plaintext(status), break_sequence: "\n> ") %> <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %> diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml index 5a2911ecb..bda1fef6c 100644 --- a/app/views/user_mailer/warning.html.haml +++ b/app/views/user_mailer/warning.html.haml @@ -37,16 +37,26 @@ %tr %td.column-cell.text-center - unless @warning.none_action? - %p= t "user_mailer.warning.explanation.#{@warning.action}" + %p= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance - unless @warning.text.blank? = Formatter.instance.linkify(@warning.text) - - if !@statuses.nil? && !@statuses.empty? + - if @warning.report && !@warning.report.other? + %p + %strong= t('user_mailer.warning.reason') + = t("user_mailer.warning.categories.#{@warning.report.category}") + + - if @warning.report.violation? && @warning.report.rule_ids.present? + %ul.rules-list + - @warning.report.rules.each do |rule| + %li= rule.text + + - unless @statuses.empty? %p %strong= t('user_mailer.warning.statuses') -- if !@statuses.nil? && !@statuses.empty? +- unless @statuses.empty? - @statuses.each_with_index do |status, i| = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb index bb6610c79..31d7308ae 100644 --- a/app/views/user_mailer/warning.text.erb +++ b/app/views/user_mailer/warning.text.erb @@ -3,11 +3,24 @@ === <% unless @warning.none_action? %> -<%= t "user_mailer.warning.explanation.#{@warning.action}" %> +<%= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance %> <% end %> +<% if @warning.text.present? %> <%= @warning.text %> -<% if !@statuses.nil? && !@statuses.empty? %> + +<% end %> +<% if @warning.report && !@warning.report.other? %> +**<%= t('user_mailer.warning.reason') %>** <%= t("user_mailer.warning.categories.#{@warning.report.category}") %> + +<% if @warning.report.violation? && @warning.report.rule_ids.present? %> +<% @warning.report.rules.each do |rule| %> +- <%= rule.text %> +<% end %> + +<% end %> +<% end %> +<% if !@statuses.empty? %> <%= t('user_mailer.warning.statuses') %> <% @statuses.each do |status| %> diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb index be0c4277d..d06b637f9 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -8,6 +8,7 @@ class Scheduler::UserCleanupScheduler def perform clean_unconfirmed_accounts! clean_suspended_accounts! + clean_discarded_statuses! end private @@ -24,4 +25,12 @@ class Scheduler::UserCleanupScheduler Admin::AccountDeletionWorker.perform_async(deletion_request.account_id) end end + + def clean_discarded_statuses! + Status.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses| + RemovalWorker.push_bulk(statuses) do |status| + [status.id, { immediate: true }] + end + end + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 693a7b400..36ac89664 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -113,6 +113,7 @@ en: confirm: Confirm confirmed: Confirmed confirming: Confirming + custom: Custom delete: Delete data deleted: Deleted demote: Demote @@ -203,6 +204,7 @@ en: silence: Limit silenced: Limited statuses: Posts + strikes: Previous strikes subscribe: Subscribe suspended: Suspended suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had. @@ -549,32 +551,44 @@ en: report_notes: created_msg: Report note successfully created! destroyed_msg: Report note successfully deleted! + today_at: Today at %{time} reports: account: notes: one: "%{count} note" other: "%{count} notes" - reports: - one: "%{count} report" - other: "%{count} reports" + action_log: Audit log action_taken_by: Action taken by + actions: + other_description_html: See more options for controlling the account's behaviour and customize communication to the reported account. + silence_description_html: The profile will be visible only to those who already follow it or manually look it up, severely limiting its reach. Can always be reverted. + suspend_description_html: The profile and all its contents will become inaccessible until it is eventually deleted. Interacting with the account will be impossible. Reversible within 30 days. + actions_description_html: 'If removing the offending content above is insufficient:' + add_to_report: Add more to report are_you_sure: Are you sure? assign_to_self: Assign to me assigned: Assigned moderator by_target_domain: Domain of reported account + category: Category + category_description_html: The reason this account and/or content was reported will be cited in communication with the reported account comment: none: None + comment_description_html: 'To provide more information, %{name} wrote:' created_at: Reported + delete_and_resolve: Delete and resolve forwarded: Forwarded forwarded_to: Forwarded to %{domain} mark_as_resolved: Mark as resolved mark_as_unresolved: Mark as unresolved + no_one_assigned: No one notes: create: Add note create_and_resolve: Resolve with note create_and_unresolve: Reopen with note delete: Delete placeholder: Describe what actions have been taken, or any other related updates... + title: Notes + notes_description_html: View and leave notes to other moderators and your future self reopen: Reopen report report: 'Report #%{id}' reported_account: Reported account @@ -582,11 +596,14 @@ en: resolved: Resolved resolved_msg: Report successfully resolved! status: Status + statuses: Reported content + statuses_description_html: Offending content will be cited in communication with the reported account target_origin: Origin of reported account title: Reports unassign: Unassign unresolved: Unresolved updated_at: Updated + view_profile: View profile rules: add_new: Add rule delete: Delete @@ -688,15 +705,13 @@ en: destroyed_msg: Site upload successfully deleted! statuses: back_to_account: Back to account page + back_to_report: Back to report page batch: - delete: Delete - nsfw_off: Mark as not sensitive - nsfw_on: Mark as sensitive + remove_from_report: Remove from report + report: Report deleted: Deleted - failed_to_execute: Failed to execute media: title: Media - no_media: No media no_status_selected: No posts were changed as none were selected title: Account posts with_media: With media @@ -1457,6 +1472,7 @@ en: formats: default: "%b %d, %Y, %H:%M" month: "%b %Y" + time: "%H:%M" two_factor_authentication: add: Add disable: Disable 2FA @@ -1484,24 +1500,31 @@ en: subject: Please confirm attempted sign in title: Sign in attempt warning: + categories: + spam: Spam + violation: Content violates the following community guidelines explanation: - disable: You can no longer login to your account or use it in any other way, but your profile and other data remains intact. - sensitive: Your uploaded media files and linked media will be treated as sensitive. - silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various public listings. However, others may still manually follow you. - suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed, but we will retain some data to prevent you from evading the suspension. - get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}. + delete_statuses: Some of your posts have been found to violate one or more community guidelines and have been subsequently removed by the moderators of %{instance}. Future violations may result in harsher punitive actions against your account. + disable: You can no longer use your account, but your profile and other data remains intact. You can request a backup of your data, change account settings or delete your account. + sensitive: From now on, all your uploaded media files will be marked as sensitive and hidden behind a click-through warning. + silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various discovery features. However, others may still manually follow you. + suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed in about 30 days, but we will retain some basic data to prevent you from evading the suspension. + get_in_touch: If you believe this is an error, you can reply to this e-mail to get in touch with the staff of %{instance}. + reason: 'Reason:' review_server_policies: Review server policies - statuses: 'Specifically, for:' + statuses: 'Posts that have been found in violation:' subject: + delete_statuses: Your posts on %{acct} have been removed disable: Your account %{acct} has been frozen none: Warning for %{acct} - sensitive: Your account %{acct} posting media has been marked as sensitive + sensitive: Your media files on %{acct} will be marked as sensitive from now on silence: Your account %{acct} has been limited suspend: Your account %{acct} has been suspended title: + delete_statuses: Posts removed disable: Account frozen none: Warning - sensitive: Your media has been marked as sensitive + sensitive: Media hidden silence: Account limited suspend: Account suspended welcome: diff --git a/config/routes.rb b/config/routes.rb index 2357ab6c7..41ba45379 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -231,8 +231,6 @@ Rails.application.routes.draw do post :reopen post :resolve end - - resources :reported_statuses, only: [:create] end resources :report_notes, only: [:create, :destroy] @@ -259,7 +257,13 @@ Rails.application.routes.draw do resource :change_email, only: [:show, :update] resource :reset, only: [:create] resource :action, only: [:new, :create], controller: 'account_actions' - resources :statuses, only: [:index, :show, :create, :update, :destroy] + + resources :statuses, only: [:index] do + collection do + post :batch + end + end + resources :relationships, only: [:index] resource :confirmation, only: [:create] do @@ -514,7 +518,7 @@ Rails.application.routes.draw do resource :action, only: [:create], controller: 'account_actions' end - resources :reports, only: [:index, :show] do + resources :reports, only: [:index, :update, :show] do member do post :assign_to_self post :unassign diff --git a/db/migrate/20211231080958_add_category_to_reports.rb b/db/migrate/20211231080958_add_category_to_reports.rb new file mode 100644 index 000000000..c2b495c63 --- /dev/null +++ b/db/migrate/20211231080958_add_category_to_reports.rb @@ -0,0 +1,21 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddCategoryToReports < ActiveRecord::Migration[6.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured { add_column_with_default :reports, :category, :int, default: 0, allow_null: false } + add_column :reports, :action_taken_at, :datetime + add_column :reports, :rule_ids, :bigint, array: true + safety_assured { execute 'UPDATE reports SET action_taken_at = updated_at WHERE action_taken = TRUE' } + end + + def down + safety_assured { execute 'UPDATE reports SET action_taken = TRUE WHERE action_taken_at IS NOT NULL' } + remove_column :reports, :category + remove_column :reports, :action_taken_at + remove_column :reports, :rule_ids + end +end diff --git a/db/migrate/20220115125126_add_report_id_to_account_warnings.rb b/db/migrate/20220115125126_add_report_id_to_account_warnings.rb new file mode 100644 index 000000000..a1c20c99e --- /dev/null +++ b/db/migrate/20220115125126_add_report_id_to_account_warnings.rb @@ -0,0 +1,6 @@ +class AddReportIdToAccountWarnings < ActiveRecord::Migration[6.1] + def change + safety_assured { add_reference :account_warnings, :report, foreign_key: { on_delete: :cascade }, index: false } + add_column :account_warnings, :status_ids, :string, array: true + end +end diff --git a/db/migrate/20220115125341_fix_account_warning_actions.rb b/db/migrate/20220115125341_fix_account_warning_actions.rb new file mode 100644 index 000000000..25cc17fd3 --- /dev/null +++ b/db/migrate/20220115125341_fix_account_warning_actions.rb @@ -0,0 +1,21 @@ +class FixAccountWarningActions < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def up + safety_assured do + execute 'UPDATE account_warnings SET action = 1000 WHERE action = 1' + execute 'UPDATE account_warnings SET action = 2000 WHERE action = 2' + execute 'UPDATE account_warnings SET action = 3000 WHERE action = 3' + execute 'UPDATE account_warnings SET action = 4000 WHERE action = 4' + end + end + + def down + safety_assured do + execute 'UPDATE account_warnings SET action = 1 WHERE action = 1000' + execute 'UPDATE account_warnings SET action = 2 WHERE action = 2000' + execute 'UPDATE account_warnings SET action = 3 WHERE action = 3000' + execute 'UPDATE account_warnings SET action = 4 WHERE action = 4000' + end + end +end diff --git a/db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb b/db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb new file mode 100644 index 000000000..dc3362552 --- /dev/null +++ b/db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb @@ -0,0 +1,7 @@ +class AddDeletedAtIndexOnStatuses < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :statuses, :deleted_at, where: 'deleted_at IS NOT NULL', algorithm: :concurrently + end +end diff --git a/db/post_migrate/20220109213908_remove_action_taken_from_reports.rb b/db/post_migrate/20220109213908_remove_action_taken_from_reports.rb new file mode 100644 index 000000000..73e6ad6f4 --- /dev/null +++ b/db/post_migrate/20220109213908_remove_action_taken_from_reports.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemoveActionTakenFromReports < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured { remove_column :reports, :action_taken, :boolean, default: false, null: false } + end +end diff --git a/db/schema.rb b/db/schema.rb index d1446c652..ed615a1ee 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_12_13_040746) do +ActiveRecord::Schema.define(version: 2022_01_16_202951) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -133,6 +133,8 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do t.text "text", default: "", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "report_id" + t.string "status_ids", array: true t.index ["account_id"], name: "index_account_warnings_on_account_id" t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id" end @@ -747,7 +749,6 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do create_table "reports", force: :cascade do |t| t.bigint "status_ids", default: [], null: false, array: true t.text "comment", default: "", null: false - t.boolean "action_taken", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "account_id", null: false @@ -756,6 +757,9 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do t.bigint "assigned_account_id" t.string "uri" t.boolean "forwarded" + t.integer "category", default: 0, null: false + t.datetime "action_taken_at" + t.bigint "rule_ids", array: true t.index ["account_id"], name: "index_reports_on_account_id" t.index ["target_account_id"], name: "index_reports_on_target_account_id" end @@ -851,6 +855,7 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do t.bigint "poll_id" t.datetime "deleted_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)))" t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" @@ -1008,6 +1013,7 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do add_foreign_key "account_statuses_cleanup_policies", "accounts", on_delete: :cascade add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade add_foreign_key "account_warnings", "accounts", on_delete: :nullify + add_foreign_key "account_warnings", "reports", on_delete: :cascade add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade add_foreign_key "announcement_mutes", "accounts", on_delete: :cascade diff --git a/spec/controllers/admin/report_notes_controller_spec.rb b/spec/controllers/admin/report_notes_controller_spec.rb index ec5872c7d..c0013f41a 100644 --- a/spec/controllers/admin/report_notes_controller_spec.rb +++ b/spec/controllers/admin/report_notes_controller_spec.rb @@ -12,11 +12,11 @@ describe Admin::ReportNotesController do describe 'POST #create' do subject { post :create, params: params } - let(:report) { Fabricate(:report, action_taken: action_taken, action_taken_by_account_id: account_id) } + let(:report) { Fabricate(:report, action_taken_at: action_taken, action_taken_by_account_id: account_id) } context 'when parameter is valid' do context 'when report is unsolved' do - let(:action_taken) { false } + let(:action_taken) { nil } let(:account_id) { nil } context 'when create_and_resolve flag is on' do @@ -41,7 +41,7 @@ describe Admin::ReportNotesController do end context 'when report is resolved' do - let(:action_taken) { true } + let(:action_taken) { Time.now.utc } let(:account_id) { user.account.id } context 'when create_and_unresolve flag is on' do @@ -68,7 +68,7 @@ describe Admin::ReportNotesController do context 'when parameter is invalid' do let(:params) { { report_note: { content: '', report_id: report.id } } } - let(:action_taken) { false } + let(:action_taken) { nil } let(:account_id) { nil } it 'renders admin/reports/show' do diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb deleted file mode 100644 index 2a1598123..000000000 --- a/spec/controllers/admin/reported_statuses_controller_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'rails_helper' - -describe Admin::ReportedStatusesController do - render_views - - let(:user) { Fabricate(:user, admin: true) } - let(:report) { Fabricate(:report, status_ids: [status.id]) } - let(:status) { Fabricate(:status) } - - before do - sign_in user, scope: :user - end - - describe 'POST #create' do - subject do - -> { post :create, params: { :report_id => report, action => '', :form_status_batch => { status_ids: status_ids } } } - end - - let(:action) { 'nsfw_on' } - let(:status_ids) { [status.id] } - let(:status) { Fabricate(:status, sensitive: !sensitive) } - let(:sensitive) { true } - let!(:media_attachment) { Fabricate(:media_attachment, status: status) } - - context 'when action is nsfw_on' do - it 'updates sensitive column' do - is_expected.to change { - status.reload.sensitive - }.from(false).to(true) - end - end - - context 'when action is nsfw_off' do - let(:action) { 'nsfw_off' } - let(:sensitive) { false } - - it 'updates sensitive column' do - is_expected.to change { - status.reload.sensitive - }.from(true).to(false) - end - end - - context 'when action is delete' do - let(:action) { 'delete' } - - it 'removes a status' do - allow(RemovalWorker).to receive(:perform_async) - subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true) - end - end - - it 'redirects to report page' do - subject.call - expect(response).to redirect_to(admin_report_path(report)) - end - end -end diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb index 49d3e9707..d421f0739 100644 --- a/spec/controllers/admin/reports_controller_spec.rb +++ b/spec/controllers/admin/reports_controller_spec.rb @@ -10,8 +10,8 @@ describe Admin::ReportsController do describe 'GET #index' do it 'returns http success with no filters' do - specified = Fabricate(:report, action_taken: false) - Fabricate(:report, action_taken: true) + specified = Fabricate(:report, action_taken_at: nil) + Fabricate(:report, action_taken_at: Time.now.utc) get :index @@ -22,10 +22,10 @@ describe Admin::ReportsController do end it 'returns http success with resolved filter' do - specified = Fabricate(:report, action_taken: true) - Fabricate(:report, action_taken: false) + specified = Fabricate(:report, action_taken_at: Time.now.utc) + Fabricate(:report, action_taken_at: nil) - get :index, params: { resolved: 1 } + get :index, params: { resolved: '1' } reports = assigns(:reports).to_a expect(reports.size).to eq 1 @@ -54,15 +54,7 @@ describe Admin::ReportsController do expect(response).to redirect_to(admin_reports_path) report.reload expect(report.action_taken_by_account).to eq user.account - expect(report.action_taken).to eq true - end - - it 'sets trust level when the report is an antispam one' do - report = Fabricate(:report, account: Account.representative) - - put :resolve, params: { id: report } - report.reload - expect(report.target_account.trust_level).to eq Account::TRUST_LEVELS[:trusted] + expect(report.action_taken?).to eq true end end @@ -74,7 +66,7 @@ describe Admin::ReportsController do expect(response).to redirect_to(admin_report_path(report)) report.reload expect(report.action_taken_by_account).to eq nil - expect(report.action_taken).to eq false + expect(report.action_taken?).to eq false end end diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index e388caae2..de32fd18e 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -18,65 +18,46 @@ describe Admin::StatusesController do end describe 'GET #index' do - it 'returns http success with no media' do - get :index, params: { account_id: account.id } + context do + before do + get :index, params: { account_id: account.id } + end - statuses = assigns(:statuses).to_a - expect(statuses.size).to eq 4 - expect(statuses.first.id).to eq last_status.id - expect(response).to have_http_status(200) + it 'returns http success' do + expect(response).to have_http_status(200) + end end - it 'returns http success with media' do - get :index, params: { account_id: account.id, media: true } + context 'filtering by media' do + before do + get :index, params: { account_id: account.id, media: '1' } + end - statuses = assigns(:statuses).to_a - expect(statuses.size).to eq 2 - expect(statuses.first.id).to eq last_media_attached_status.id - expect(response).to have_http_status(200) + it 'returns http success' do + expect(response).to have_http_status(200) + end end end - describe 'POST #create' do - subject do - -> { post :create, params: { :account_id => account.id, action => '', :form_status_batch => { status_ids: status_ids } } } + describe 'POST #batch' do + before do + post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } } end - let(:action) { 'nsfw_on' } let(:status_ids) { [media_attached_status.id] } - context 'when action is nsfw_on' do - it 'updates sensitive column' do - is_expected.to change { - media_attached_status.reload.sensitive - }.from(false).to(true) - end - end + context 'when action is report' do + let(:action) { 'report' } - context 'when action is nsfw_off' do - let(:action) { 'nsfw_off' } - let(:sensitive) { false } - - it 'updates sensitive column' do - is_expected.to change { - media_attached_status.reload.sensitive - }.from(true).to(false) + it 'creates a report' do + report = Report.last + expect(report.target_account_id).to eq account.id + expect(report.status_ids).to eq status_ids end - end - - context 'when action is delete' do - let(:action) { 'delete' } - it 'removes a status' do - allow(RemovalWorker).to receive(:perform_async) - subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true) + it 'redirects to report page' do + expect(response).to redirect_to(admin_report_path(Report.last.id)) end end - - it 'redirects to account statuses page' do - subject.call - expect(response).to redirect_to(admin_account_statuses_path(account.id)) - end end end diff --git a/spec/fabricators/report_fabricator.rb b/spec/fabricators/report_fabricator.rb index 5bd4a63f0..2c7101e09 100644 --- a/spec/fabricators/report_fabricator.rb +++ b/spec/fabricators/report_fabricator.rb @@ -1,6 +1,6 @@ Fabricator(:report) do account - target_account { Fabricate(:account) } - comment "You nasty" - action_taken false + target_account { Fabricate(:account) } + comment "You nasty" + action_taken_at nil end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 6d87fd706..69b9b971e 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -79,7 +79,7 @@ class UserMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning def warning - UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id]) + UserMailer.warning(User.first, AccountWarning.last) end # Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb deleted file mode 100644 index 68d84a737..000000000 --- a/spec/models/form/status_batch_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'rails_helper' - -describe Form::StatusBatch do - let(:form) { Form::StatusBatch.new(action: action, status_ids: status_ids) } - let(:status) { Fabricate(:status) } - - describe 'with nsfw action' do - let(:status_ids) { [status.id, nonsensitive_status.id, sensitive_status.id] } - let(:nonsensitive_status) { Fabricate(:status, sensitive: false) } - let(:sensitive_status) { Fabricate(:status, sensitive: true) } - let!(:shown_media_attachment) { Fabricate(:media_attachment, status: nonsensitive_status) } - let!(:hidden_media_attachment) { Fabricate(:media_attachment, status: sensitive_status) } - - context 'nsfw_on' do - let(:action) { 'nsfw_on' } - - it { expect(form.save).to be true } - it { expect { form.save }.to change { nonsensitive_status.reload.sensitive }.from(false).to(true) } - it { expect { form.save }.not_to change { sensitive_status.reload.sensitive } } - it { expect { form.save }.not_to change { status.reload.sensitive } } - end - - context 'nsfw_off' do - let(:action) { 'nsfw_off' } - - it { expect(form.save).to be true } - it { expect { form.save }.to change { sensitive_status.reload.sensitive }.from(true).to(false) } - it { expect { form.save }.not_to change { nonsensitive_status.reload.sensitive } } - it { expect { form.save }.not_to change { status.reload.sensitive } } - end - end - - describe 'with delete action' do - let(:status_ids) { [status.id] } - let(:action) { 'delete' } - let!(:another_status) { Fabricate(:status) } - - before do - allow(RemovalWorker).to receive(:perform_async) - end - - it 'call RemovalWorker' do - form.save - expect(RemovalWorker).to have_received(:perform_async).with(status.id, immediate: true) - end - - it 'do not call RemovalWorker' do - form.save - expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, immediate: true) - end - end -end diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 312954c9d..3d29c0219 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -54,7 +54,7 @@ describe Report do end describe 'resolve!' do - subject(:report) { Fabricate(:report, action_taken: false, action_taken_by_account_id: nil) } + subject(:report) { Fabricate(:report, action_taken_at: nil, action_taken_by_account_id: nil) } let(:acting_account) { Fabricate(:account) } @@ -63,12 +63,13 @@ describe Report do end it 'records action taken' do - expect(report).to have_attributes(action_taken: true, action_taken_by_account_id: acting_account.id) + expect(report.action_taken?).to be true + expect(report.action_taken_by_account_id).to eq acting_account.id end end describe 'unresolve!' do - subject(:report) { Fabricate(:report, action_taken: true, action_taken_by_account_id: acting_account.id) } + subject(:report) { Fabricate(:report, action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id) } let(:acting_account) { Fabricate(:account) } @@ -77,23 +78,24 @@ describe Report do end it 'unresolves' do - expect(report).to have_attributes(action_taken: false, action_taken_by_account_id: nil) + expect(report.action_taken?).to be false + expect(report.action_taken_by_account_id).to be_nil end end describe 'unresolved?' do subject { report.unresolved? } - let(:report) { Fabricate(:report, action_taken: action_taken) } + let(:report) { Fabricate(:report, action_taken_at: action_taken) } context 'if action is taken' do - let(:action_taken) { true } + let(:action_taken) { Time.now.utc } it { is_expected.to be false } end context 'if action not is taken' do - let(:action_taken) { false } + let(:action_taken) { nil } it { is_expected.to be true } end -- cgit From 03d59340da3ffc380d7b27169bdc887140d88bee Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 28 Jan 2022 00:43:56 +0100 Subject: Fix Sidekiq warnings about JSON serialization (#17381) * Fix Sidekiq warnings about JSON serialization This occurs on every symbol argument we pass, and every symbol key in hashes, because Sidekiq expects strings instead. See https://github.com/mperham/sidekiq/pull/5071 We do not need to change how workers parse their arguments because this has not changed and we were already converting to symbols adequately or using `with_indifferent_access`. * Set Sidekiq to raise on unsafe arguments in test mode In order to more easily catch issues that would produce warnings in production code. --- app/controllers/api/v1/statuses_controller.rb | 2 +- app/lib/activitypub/activity/create.rb | 4 ++-- app/lib/feed_manager.rb | 4 ++-- app/models/admin/status_batch_action.rb | 2 +- app/models/form/account_batch.rb | 2 +- app/services/account_statuses_cleanup_service.rb | 2 +- app/services/activitypub/process_status_update_service.rb | 2 +- app/services/fan_out_on_write_service.rb | 8 ++++---- app/services/follow_service.rb | 4 ++-- app/services/import_service.rb | 4 ++-- app/services/reblog_service.rb | 2 +- app/services/resolve_account_service.rb | 2 +- app/workers/activitypub/distribution_worker.rb | 2 +- app/workers/feed_insert_worker.rb | 2 +- app/workers/scheduler/user_cleanup_scheduler.rb | 2 +- config/environments/test.rb | 3 +++ 16 files changed, 25 insertions(+), 22 deletions(-) (limited to 'app/workers/scheduler') diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224..98b1776ea 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -56,7 +56,7 @@ class Api::V1::StatusesController < Api::BaseController authorize @status, :destroy? @status.discard - RemovalWorker.perform_async(@status.id, redraft: true) + RemovalWorker.perform_async(@status.id, { 'redraft' => true }) @status.account.statuses_count = @status.account.statuses_count - 1 render json: @status, serializer: REST::StatusSerializer, source_requested: true diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index a861c34bc..33998c477 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -94,7 +94,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity 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? + ::DistributionWorker.perform_async(@status.id, { 'silenced_account_ids' => @silenced_account_ids }) if @options[:override_timestamps] || @status.within_realtime_window? end def find_existing_status @@ -168,7 +168,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity return unless delivered_to_account.following?(@account) - FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home) + FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, 'home') end def delivered_to_account diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index c4dd9d00f..7840afee8 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -59,7 +59,7 @@ class FeedManager 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}", update: update) 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 @@ -84,7 +84,7 @@ class FeedManager 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}", update: update) 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 diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb index 319deff98..85822214b 100644 --- a/app/models/admin/status_batch_action.rb +++ b/app/models/admin/status_batch_action.rb @@ -56,7 +56,7 @@ class Admin::StatusBatchAction end UserMailer.warning(target_account.user, @warning).deliver_later! if target_account.local? - RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, preserve: target_account.local?, immediate: !target_account.local?] } + RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, { 'preserve' => target_account.local?, 'immediate' => !target_account.local? }] } end def handle_report! diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb index 4bf1775bb..dcf155840 100644 --- a/app/models/form/account_batch.rb +++ b/app/models/form/account_batch.rb @@ -103,7 +103,7 @@ class Form::AccountBatch authorize(account.user, :reject?) log_action(:reject, account.user, username: account.username) account.suspend!(origin: :local) - AccountDeletionWorker.perform_async(account.id, reserve_username: false) + AccountDeletionWorker.perform_async(account.id, { 'reserve_username' => false }) end def suspend_account(account) diff --git a/app/services/account_statuses_cleanup_service.rb b/app/services/account_statuses_cleanup_service.rb index cbadecc63..3918b5ba4 100644 --- a/app/services/account_statuses_cleanup_service.rb +++ b/app/services/account_statuses_cleanup_service.rb @@ -15,7 +15,7 @@ class AccountStatusesCleanupService < BaseService account_policy.statuses_to_delete(budget, cutoff_id, account_policy.last_inspected).reorder(nil).find_each(order: :asc) do |status| status.discard - RemovalWorker.perform_async(status.id, redraft: false) + RemovalWorker.perform_async(status.id, { 'redraft' => false }) num_deleted += 1 last_deleted = status.id end diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index e22ea1ab6..977928127 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -266,7 +266,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService end def broadcast_updates! - ::DistributionWorker.perform_async(@status.id, update: true) + ::DistributionWorker.perform_async(@status.id, { 'update' => true }) end def queue_poll_notifications! diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 3a2e82b6d..4f847e293 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -59,7 +59,7 @@ class FanOutOnWriteService < BaseService 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] + [mention.account_id, mention.id, 'Mention', 'mention'] end end end @@ -67,7 +67,7 @@ class FanOutOnWriteService < BaseService 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, update: update?] + [@status.id, follower.id, 'home', { 'update' => update? }] end end end @@ -75,7 +75,7 @@ class FanOutOnWriteService < BaseService 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, update: update?] + [@status.id, list.id, 'list', { 'update' => update? }] end end end @@ -83,7 +83,7 @@ class FanOutOnWriteService < BaseService 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, update: update?] + [@status.id, mention.account_id, 'home', { 'update' => update? }] end end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 329262cca..ed28e1371 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -68,7 +68,7 @@ class FollowService < BaseService follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]) if @target_account.local? - LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, :follow_request) + LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request') elsif @target_account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url) end @@ -79,7 +79,7 @@ class FollowService < BaseService def direct_follow! follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]) - LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, :follow) + LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, 'follow') MergeWorker.perform_async(@target_account.id, @source_account.id) follow diff --git a/app/services/import_service.rb b/app/services/import_service.rb index 74ad5b79f..8e6640b9d 100644 --- a/app/services/import_service.rb +++ b/app/services/import_service.rb @@ -76,7 +76,7 @@ class ImportService < BaseService if presence_hash[target_account.acct] items.delete(target_account.acct) extra = presence_hash[target_account.acct][1] - Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, extra) + Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, extra.stringify_keys) else Import::RelationshipWorker.perform_async(@account.id, target_account.acct, undo_action) end @@ -87,7 +87,7 @@ class ImportService < BaseService tail_items = items - head_items Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra| - [@account.id, acct, action, extra] + [@account.id, acct, action, extra.stringify_keys] end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index ece91847a..2d1265f10 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -47,7 +47,7 @@ class ReblogService < BaseService reblogged_status = reblog.reblog if reblogged_status.account.local? - LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, :reblog) + LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog') elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account) ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url) end diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index b266c019e..3a372ef2a 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -143,7 +143,7 @@ class ResolveAccountService < BaseService def queue_deletion! @account.suspend!(origin: :remote) - AccountDeletionWorker.perform_async(@account.id, reserve_username: false, skip_activitypub: true) + AccountDeletionWorker.perform_async(@account.id, { 'reserve_username' => false, 'skip_activitypub' => true }) end def lock_options diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 17c108461..575e11025 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -27,6 +27,6 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker end def options - { synchronize_followers: @status.private_visibility? } + { 'synchronize_followers' => @status.private_visibility? } end end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 0122be95d..6e3472d57 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -3,7 +3,7 @@ class FeedInsertWorker include Sidekiq::Worker - def perform(status_id, id, type = :home, options = {}) + def perform(status_id, id, type = 'home', options = {}) @type = type.to_sym @status = Status.find(status_id) @options = options.symbolize_keys diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb index d06b637f9..750d2127b 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -29,7 +29,7 @@ class Scheduler::UserCleanupScheduler def clean_discarded_statuses! Status.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses| RemovalWorker.push_bulk(statuses) do |status| - [status.id, { immediate: true }] + [status.id, { 'immediate' => true }] end end end diff --git a/config/environments/test.rb b/config/environments/test.rb index a35cadcfa..ef3cb2e48 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -70,3 +70,6 @@ if ENV['PAM_ENABLED'] == 'true' env: { email: 'pam@example.com' } } end + +# Catch serialization warnings early +Sidekiq.strict_args! -- cgit