From a29a982eaa0536a741b43ffb3397c74e3abe7196 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 24 Feb 2022 17:28:23 +0100 Subject: Change e-mail domain blocks to block IPs dynamically (#17635) * Change e-mail domain blocks to block IPs dynamically * Update app/workers/scheduler/email_domain_block_refresh_scheduler.rb Co-authored-by: Yamagishi Kazutoshi * Update app/workers/scheduler/email_domain_block_refresh_scheduler.rb Co-authored-by: Yamagishi Kazutoshi Co-authored-by: Yamagishi Kazutoshi --- .../email_domain_block_refresh_scheduler.rb | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/workers/scheduler/email_domain_block_refresh_scheduler.rb (limited to 'app/workers') diff --git a/app/workers/scheduler/email_domain_block_refresh_scheduler.rb b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb new file mode 100644 index 000000000..c67be6843 --- /dev/null +++ b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Scheduler::EmailDomainBlockRefreshScheduler + include Sidekiq::Worker + include Redisable + + sidekiq_options retry: 0 + + def perform + Resolv::DNS.open do |dns| + dns.timeouts = 5 + + EmailDomainBlock.find_each do |email_domain_block| + ips = begin + if ip?(email_domain_block.domain) + [email_domain_block.domain] + else + dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::A).to_a + dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::AAAA).to_a.map { |resource| resource.address.to_s } + end + end + + email_domain_block.update(ips: ips, last_refresh_at: Time.now.utc) + end + end + end + + def ip?(str) + str =~ Regexp.union([Resolv::IPv4::Regex, Resolv::IPv6::Regex]) + end +end -- cgit From 27965ce5edff20db2de1dd233c88f8393bb0da0b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 25 Feb 2022 00:34:14 +0100 Subject: Add trending statuses (#17431) * Add trending statuses * Fix dangling items with stale scores in localized sets * Various fixes and improvements - Change approve_all/reject_all to approve_accounts/reject_accounts - Change Trends::Query methods to not mutate the original query - Change Trends::Query#skip to offset - Change follow recommendations to be refreshed in a transaction * Add tests for trending statuses filtering behaviour * Fix not applying filtering scope in controller --- .rubocop.yml | 5 +- .../links/preview_card_providers_controller.rb | 12 +- app/controllers/admin/trends/links_controller.rb | 20 +-- .../admin/trends/statuses_controller.rb | 45 +++++++ app/controllers/admin/trends/tags_controller.rb | 12 +- .../api/v1/admin/trends/links_controller.rb | 19 +++ .../api/v1/admin/trends/statuses_controller.rb | 19 +++ .../api/v1/admin/trends/tags_controller.rb | 2 +- app/controllers/api/v1/trends/links_controller.rb | 6 +- .../api/v1/trends/statuses_controller.rb | 27 ++++ app/controllers/api/v1/trends/tags_controller.rb | 2 +- app/controllers/concerns/localized.rb | 4 + app/helpers/admin/filter_helper.rb | 7 +- app/helpers/languages_helper.rb | 2 +- app/javascript/styles/mastodon/accounts.scss | 10 +- app/javascript/styles/mastodon/tables.scss | 7 + app/lib/activitypub/activity/announce.rb | 3 +- app/lib/activitypub/activity/like.rb | 2 + app/mailers/admin_mailer.rb | 27 ++-- app/models/account.rb | 30 +++-- app/models/form/preview_card_batch.rb | 65 ---------- app/models/form/preview_card_provider_batch.rb | 33 ----- app/models/form/tag_batch.rb | 37 ------ app/models/preview_card_filter.rb | 53 -------- app/models/preview_card_provider_filter.rb | 49 ------- app/models/status.rb | 12 ++ app/models/tag_filter.rb | 66 ---------- app/models/trends.rb | 26 +++- app/models/trends/base.rb | 20 ++- app/models/trends/links.rb | 52 ++++---- app/models/trends/preview_card_batch.rb | 65 ++++++++++ app/models/trends/preview_card_filter.rb | 46 +++++++ app/models/trends/preview_card_provider_batch.rb | 33 +++++ app/models/trends/preview_card_provider_filter.rb | 49 +++++++ app/models/trends/query.rb | 106 +++++++++++++++ app/models/trends/status_batch.rb | 65 ++++++++++ app/models/trends/status_filter.rb | 46 +++++++ app/models/trends/statuses.rb | 142 +++++++++++++++++++++ app/models/trends/tag_batch.rb | 37 ++++++ app/models/trends/tag_filter.rb | 60 +++++++++ app/models/trends/tags.rb | 36 ++---- app/models/user.rb | 2 +- app/policies/account_policy.rb | 4 + app/policies/preview_card_policy.rb | 2 +- app/policies/preview_card_provider_policy.rb | 2 +- app/policies/status_policy.rb | 4 + app/policies/tag_policy.rb | 4 + app/services/delete_account_service.rb | 32 ++--- app/services/favourite_service.rb | 2 + app/services/reblog_service.rb | 3 +- .../admin/custom_emojis/_custom_emoji.html.haml | 2 +- .../admin/follow_recommendations/show.html.haml | 6 +- app/views/admin/trends/links/index.html.haml | 34 +++-- .../links/preview_card_providers/index.html.haml | 2 +- app/views/admin/trends/statuses/_status.html.haml | 30 +++++ app/views/admin/trends/statuses/index.html.haml | 43 +++++++ app/views/admin/trends/tags/index.html.haml | 4 +- .../admin_mailer/_new_trending_links.text.erb | 14 ++ .../admin_mailer/_new_trending_statuses.text.erb | 14 ++ app/views/admin_mailer/_new_trending_tags.text.erb | 14 ++ app/views/admin_mailer/new_trending_links.text.erb | 16 --- app/views/admin_mailer/new_trending_tags.text.erb | 16 --- app/views/admin_mailer/new_trends.text.erb | 13 ++ app/views/application/_sidebar.html.haml | 2 +- .../scheduler/follow_recommendations_scheduler.rb | 8 +- config/brakeman.ignore | 68 ++++------ config/locales/en.yml | 34 +++-- config/navigation.rb | 1 + config/routes.rb | 9 ++ .../20220202200743_add_trendable_to_accounts.rb | 7 + .../20220202200926_add_trendable_to_statuses.rb | 5 + ...20202201015_remove_trust_level_from_accounts.rb | 9 ++ db/schema.rb | 6 +- .../api/v1/trends/tags_controller_spec.rb | 7 +- spec/mailers/previews/admin_mailer_preview.rb | 11 +- spec/models/trends/statuses_spec.rb | 110 ++++++++++++++++ spec/models/trends/tags_spec.rb | 6 +- 77 files changed, 1336 insertions(+), 569 deletions(-) create mode 100644 app/controllers/admin/trends/statuses_controller.rb create mode 100644 app/controllers/api/v1/admin/trends/links_controller.rb create mode 100644 app/controllers/api/v1/admin/trends/statuses_controller.rb create mode 100644 app/controllers/api/v1/trends/statuses_controller.rb delete mode 100644 app/models/form/preview_card_batch.rb delete mode 100644 app/models/form/preview_card_provider_batch.rb delete mode 100644 app/models/form/tag_batch.rb delete mode 100644 app/models/preview_card_filter.rb delete mode 100644 app/models/preview_card_provider_filter.rb delete mode 100644 app/models/tag_filter.rb create mode 100644 app/models/trends/preview_card_batch.rb create mode 100644 app/models/trends/preview_card_filter.rb create mode 100644 app/models/trends/preview_card_provider_batch.rb create mode 100644 app/models/trends/preview_card_provider_filter.rb create mode 100644 app/models/trends/query.rb create mode 100644 app/models/trends/status_batch.rb create mode 100644 app/models/trends/status_filter.rb create mode 100644 app/models/trends/statuses.rb create mode 100644 app/models/trends/tag_batch.rb create mode 100644 app/models/trends/tag_filter.rb create mode 100644 app/views/admin/trends/statuses/_status.html.haml create mode 100644 app/views/admin/trends/statuses/index.html.haml create mode 100644 app/views/admin_mailer/_new_trending_links.text.erb create mode 100644 app/views/admin_mailer/_new_trending_statuses.text.erb create mode 100644 app/views/admin_mailer/_new_trending_tags.text.erb delete mode 100644 app/views/admin_mailer/new_trending_links.text.erb delete mode 100644 app/views/admin_mailer/new_trending_tags.text.erb create mode 100644 app/views/admin_mailer/new_trends.text.erb create mode 100644 db/migrate/20220202200743_add_trendable_to_accounts.rb create mode 100644 db/migrate/20220202200926_add_trendable_to_statuses.rb create mode 100644 db/post_migrate/20220202201015_remove_trust_level_from_accounts.rb create mode 100644 spec/models/trends/statuses_spec.rb (limited to 'app/workers') diff --git a/.rubocop.yml b/.rubocop.yml index 2af0f59bb..68634e9e3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -32,10 +32,11 @@ Layout/EmptyLineAfterGuardClause: Layout/EmptyLinesAroundAttributeAccessor: Enabled: true +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + Layout/HashAlignment: Enabled: false - # EnforcedHashRocketStyle: table - # EnforcedColonStyle: table Layout/SpaceAroundMethodCallOperator: Enabled: true diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb index 2c26e03f3..40a466cd6 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -5,11 +5,11 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll authorize :preview_card_provider, :index? @preview_card_providers = filtered_preview_card_providers.page(params[:page]) - @form = Form::PreviewCardProviderBatch.new + @form = Trends::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 = Trends::PreviewCardProviderBatch.new(trends_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') @@ -20,15 +20,15 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll private def filtered_preview_card_providers - PreviewCardProviderFilter.new(filter_params).results + Trends::PreviewCardProviderFilter.new(filter_params).results end def filter_params - params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS) + params.slice(:page, *Trends::PreviewCardProviderFilter::KEYS).permit(:page, *Trends::PreviewCardProviderFilter::KEYS) end - def form_preview_card_provider_batch_params - params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) + def trends_preview_card_provider_batch_params + params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) end def action_from_button diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index 619b37deb..434eec5fe 100644 --- a/app/controllers/admin/trends/links_controller.rb +++ b/app/controllers/admin/trends/links_controller.rb @@ -5,11 +5,11 @@ class Admin::Trends::LinksController < Admin::BaseController authorize :preview_card, :index? @preview_cards = filtered_preview_cards.page(params[:page]) - @form = Form::PreviewCardBatch.new + @form = Trends::PreviewCardBatch.new end def batch - @form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button)) + @form = Trends::PreviewCardBatch.new(trends_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') @@ -20,26 +20,26 @@ class Admin::Trends::LinksController < Admin::BaseController private def filtered_preview_cards - PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results + Trends::PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results end def filter_params - params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS) + params.slice(:page, *Trends::PreviewCardFilter::KEYS).permit(:page, *Trends::PreviewCardFilter::KEYS) end - def form_preview_card_batch_params - params.require(:form_preview_card_batch).permit(:action, preview_card_ids: []) + def trends_preview_card_batch_params + params.require(:trends_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[:approve_providers] + 'approve_providers' elsif params[:reject] 'reject' - elsif params[:reject_all] - 'reject_all' + elsif params[:reject_providers] + 'reject_providers' end end end diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb new file mode 100644 index 000000000..766242738 --- /dev/null +++ b/app/controllers/admin/trends/statuses_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Admin::Trends::StatusesController < Admin::BaseController + def index + authorize :status, :index? + + @statuses = filtered_statuses.page(params[:page]) + @form = Trends::StatusBatch.new + end + + def batch + @form = Trends::StatusBatch.new(trends_status_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_statuses_path(filter_params) + end + + private + + def filtered_statuses + Trends::StatusFilter.new(filter_params.with_defaults(trending: 'all')).results.includes(:account, :media_attachments, :active_mentions) + end + + def filter_params + params.slice(:page, *Trends::StatusFilter::KEYS).permit(:page, *Trends::StatusFilter::KEYS) + end + + def trends_status_batch_params + params.require(:trends_status_batch).permit(:action, status_ids: []) + end + + def action_from_button + if params[:approve] + 'approve' + elsif params[:approve_accounts] + 'approve_accounts' + elsif params[:reject] + 'reject' + elsif params[:reject_accounts] + 'reject_accounts' + end + end +end diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index 91ff33d40..f4d1ec0d1 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -5,11 +5,11 @@ class Admin::Trends::TagsController < Admin::BaseController authorize :tag, :index? @tags = filtered_tags.page(params[:page]) - @form = Form::TagBatch.new + @form = Trends::TagBatch.new end def batch - @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button)) + @form = Trends::TagBatch.new(trends_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') @@ -20,15 +20,15 @@ class Admin::Trends::TagsController < Admin::BaseController private def filtered_tags - TagFilter.new(filter_params).results + Trends::TagFilter.new(filter_params).results end def filter_params - params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) + params.slice(:page, *Trends::TagFilter::KEYS).permit(:page, *Trends::TagFilter::KEYS) end - def form_tag_batch_params - params.require(:form_tag_batch).permit(:action, tag_ids: []) + def trends_tag_batch_params + params.require(:trends_tag_batch).permit(:action, tag_ids: []) end def action_from_button diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb new file mode 100644 index 000000000..63b3d9358 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/links_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V1::Admin::Trends::LinksController < Api::BaseController + protect_from_forgery with: :exception + + before_action -> { authorize_if_got_token! :'admin:read' } + before_action :require_staff! + before_action :set_links + + def index + render json: @links, each_serializer: REST::Trends::LinkSerializer + end + + private + + def set_links + @links = Trends.links.query.limit(limit_param(10)) + end +end diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb new file mode 100644 index 000000000..86633cc74 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V1::Admin::Trends::StatusesController < Api::BaseController + protect_from_forgery with: :exception + + before_action -> { authorize_if_got_token! :'admin:read' } + before_action :require_staff! + before_action :set_statuses + + def index + render json: @statuses, each_serializer: REST::StatusSerializer + end + + private + + def set_statuses + @statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) + end +end diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb index 4815af31e..5cc4c269d 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -14,6 +14,6 @@ class Api::V1::Admin::Trends::TagsController < Api::BaseController private def set_tags - @tags = Trends.tags.get(false, limit_param(10)) + @tags = Trends.tags.query.limit(limit_param(10)) end end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 1c3ab1e1c..ad20e7f8b 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -12,10 +12,14 @@ class Api::V1::Trends::LinksController < Api::BaseController def set_links @links = begin if Setting.trends - Trends.links.get(true, limit_param(10)) + links_from_trends else [] end end end + + def links_from_trends + Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10)) + end end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb new file mode 100644 index 000000000..d4ec97ae5 --- /dev/null +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::Trends::StatusesController < Api::BaseController + before_action :set_statuses + + def index + render json: @statuses, each_serializer: REST::StatusSerializer + end + + private + + def set_statuses + @statuses = begin + if Setting.trends + cache_collection(statuses_from_trends, Status) + else + [] + end + end + end + + def statuses_from_trends + scope = Trends.statuses.query.allowed.in_locale(content_locale) + scope = scope.filtered_for(current_account) if user_signed_in? + scope.limit(limit_param(DEFAULT_STATUSES_LIMIT)) + end +end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 947b53de2..1334b72d2 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Trends::TagsController < Api::BaseController def set_tags @tags = begin if Setting.trends - Trends.tags.get(true, limit_param(10)) + Trends.tags.query.allowed.limit(limit_param(10)) else [] end diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index 173316800..ede299d5a 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -27,4 +27,8 @@ module Localized def available_locale_or_nil(locale_name) locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym) end + + def content_locale + @content_locale ||= I18n.locale.to_s.split(/[_-]/).first + end end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 907529b37..140fc73ed 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -5,9 +5,10 @@ module Admin::FilterHelper AccountFilter::KEYS, CustomEmojiFilter::KEYS, ReportFilter::KEYS, - TagFilter::KEYS, - PreviewCardProviderFilter::KEYS, - PreviewCardFilter::KEYS, + Trends::TagFilter::KEYS, + Trends::PreviewCardProviderFilter::KEYS, + Trends::PreviewCardFilter::KEYS, + Trends::StatusFilter::KEYS, InstanceFilter::KEYS, InviteFilter::KEYS, RelationshipFilter::KEYS, diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 3a65af686..f22cc6d28 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -242,6 +242,6 @@ module LanguagesHelper end def valid_locale?(locale) - SUPPORTED_LOCALES.key?(locale.to_sym) + locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym) end end diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 485fe4a9d..215774a19 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -331,7 +331,8 @@ } .batch-table__row--muted .pending-account__header, -.batch-table__row--muted .accounts-table { +.batch-table__row--muted .accounts-table, +.batch-table__row--muted .name-tag { &, a, strong { @@ -339,6 +340,10 @@ } } +.batch-table__row--muted .name-tag .avatar { + opacity: 0.5; +} + .batch-table__row--muted .accounts-table { tbody td.accounts-table__extra, &__count, @@ -352,7 +357,8 @@ } .batch-table__row--attention .pending-account__header, -.batch-table__row--attention .accounts-table { +.batch-table__row--attention .accounts-table, +.batch-table__row--attention .name-tag { &, a, strong { diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 36bc07a72..1f7e71776 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -210,6 +210,7 @@ a.table-action-link { &__content { padding-top: 12px; padding-bottom: 16px; + overflow: hidden; &--unpadded { padding: 0; @@ -296,3 +297,9 @@ a.table-action-link { } } } + +.one-liner { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 12fad8da4..7cd5a41e8 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -23,8 +23,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity visibility: visibility_from_audience ) - Trends.tags.register(@status) - Trends.links.register(@status) + Trends.register!(@status) distribute end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index c065f01f8..ebbda15b9 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -7,6 +7,8 @@ class ActivityPub::Activity::Like < ActivityPub::Activity return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) favourite = original_status.favourites.create!(account: @account) + NotifyService.new.call(original_status.account, :favourite, favourite) + Trends.statuses.register(original_status) end end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index a9d00c000..f416977d8 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -35,25 +35,18 @@ class AdminMailer < ApplicationMailer end end - 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.options[:review_threshold]).last + def new_trends(recipient, links, tags, statuses) + @links = links + @lowest_trending_link = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last + @tags = tags + @lowest_trending_tag = Trends.tags.query.allowed.limit(Trends.tags.options[:review_threshold]).last + @statuses = statuses + @lowest_trending_status = Trends.statuses.query.allowed.limit(Trends.statuses.options[:review_threshold]).last + @me = recipient + @instance = Rails.configuration.x.local_domain 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.options[:review_threshold]).last - - locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance) + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance) end end end diff --git a/app/models/account.rb b/app/models/account.rb index 2ad45feda..dfdf9045f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -40,13 +40,15 @@ # also_known_as :string is an Array # silenced_at :datetime # suspended_at :datetime -# trust_level :integer # hide_collections :boolean # avatar_storage_schema_version :integer # header_storage_schema_version :integer # devices_url :string # suspension_origin :integer # sensitized_at :datetime +# trendable :boolean +# reviewed_at :datetime +# requested_review_at :datetime # class Account < ApplicationRecord @@ -56,6 +58,7 @@ class Account < ApplicationRecord remote_url salmon_url hub_url + trust_level ) USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i @@ -74,11 +77,6 @@ class Account < ApplicationRecord include DomainMaterializable include AccountMerging - TRUST_LEVELS = { - untrusted: 0, - trusted: 1, - }.freeze - enum protocol: [:ostatus, :activitypub] enum suspension_origin: [:local, :remote], _prefix: true @@ -202,10 +200,6 @@ class Account < ApplicationRecord last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago end - def trust_level - self[:trust_level] || 0 - end - def refresh! ResolveAccountService.new.call(acct) unless local? end @@ -388,6 +382,22 @@ class Account < ApplicationRecord @synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/" end + 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 + class Field < ActiveModelSerializers::Model attributes :name, :value, :verified_at, :account diff --git a/app/models/form/preview_card_batch.rb b/app/models/form/preview_card_batch.rb deleted file mode 100644 index 5f6e6522a..000000000 --- a/app/models/form/preview_card_batch.rb +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100644 index e6ab3d8fa..000000000 --- a/app/models/form/preview_card_provider_batch.rb +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index b9330745f..000000000 --- a/app/models/form/tag_batch.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class Form::TagBatch - include ActiveModel::Model - include Authorization - - attr_accessor :tag_ids, :action, :current_account - - def save - case action - when 'approve' - approve! - when 'reject' - reject! - end - end - - private - - def tags - Tag.where(id: tag_ids) - end - - def approve! - tags.each { |tag| authorize(tag, :update?) } - 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: action_time) - end - - def action_time - @action_time ||= Time.now.utc - end -end diff --git a/app/models/preview_card_filter.rb b/app/models/preview_card_filter.rb deleted file mode 100644 index 8dda9989c..000000000 --- a/app/models/preview_card_filter.rb +++ /dev/null @@ -1,53 +0,0 @@ -# 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_filter.rb b/app/models/preview_card_provider_filter.rb deleted file mode 100644 index 1e90d3c9d..000000000 --- a/app/models/preview_card_provider_filter.rb +++ /dev/null @@ -1,49 +0,0 @@ -# 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/status.rb b/app/models/status.rb index 96e41b1d3..adb92ef91 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -268,6 +268,18 @@ class Status < ApplicationRecord update_status_stat!(key => [public_send(key) - 1, 0].max) end + def trendable? + if attributes['trendable'].nil? + account.trendable? + else + attributes['trendable'] + end + end + + def requires_review_notification? + attributes['trendable'].nil? && account.requires_review_notification? + end + after_create_commit :increment_counter_caches after_destroy_commit :decrement_counter_caches diff --git a/app/models/tag_filter.rb b/app/models/tag_filter.rb deleted file mode 100644 index ecdb52503..000000000 --- a/app/models/tag_filter.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class TagFilter - KEYS = %i( - trending - status - ).freeze - - attr_reader :params - - def initialize(params) - @params = params - end - - def results - 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' - - 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 '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/trends.rb b/app/models/trends.rb index 7dd3a9c87..f8864e55f 100644 --- a/app/models/trends.rb +++ b/app/models/trends.rb @@ -13,15 +13,37 @@ module Trends @tags ||= Trends::Tags.new end + def self.statuses + @statuses ||= Trends::Statuses.new + end + + def self.register!(status) + [links, tags, statuses].each { |trend_type| trend_type.register(status) } + end + def self.refresh! - [links, tags].each(&:refresh) + [links, tags, statuses].each(&:refresh) end def self.request_review! - [links, tags].each(&:request_review) if enabled? + return unless enabled? + + links_requiring_review = links.request_review + tags_requiring_review = tags.request_review + statuses_requiring_review = statuses.request_review + + return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty? + + User.staff.includes(:account).find_each do |user| + AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails? + end end def self.enabled? Setting.trends end + + def self.available_locales + @available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq + end end diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb index b767dcb1a..7ed13228d 100644 --- a/app/models/trends/base.rb +++ b/app/models/trends/base.rb @@ -2,6 +2,7 @@ class Trends::Base include Redisable + include LanguagesHelper class_attribute :default_options @@ -32,8 +33,8 @@ class Trends::Base raise NotImplementedError end - def get(*) - raise NotImplementedError + def query + Trends::Query.new(key_prefix, klass) end def score(id) @@ -72,6 +73,21 @@ class Trends::Base redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0 end + # @param [Integer] id + # @param [Float] score + # @param [Hash] subsets + def add_to_and_remove_from_subsets(id, score, subsets = {}) + subsets.each_key do |subset| + key = [key_prefix, subset].compact.join(':') + + if score.positive? && subsets[subset] + redis.zadd(key, score, id) + else + redis.zrem(key, id) + end + end + end + private def used_key(at_time) diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb index a0d65138b..62308e706 100644 --- a/app/models/trends/links.rb +++ b/app/models/trends/links.rb @@ -4,8 +4,8 @@ class Trends::Links < Trends::Base PREFIX = 'trending_links' self.default_options = { - threshold: 15, - review_threshold: 10, + threshold: 5, + review_threshold: 3, max_score_cooldown: 2.days.freeze, max_score_halflife: 8.hours.freeze, } @@ -27,12 +27,6 @@ class Trends::Links < Trends::Base 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) @@ -42,7 +36,7 @@ class Trends::Links < Trends::Base def request_review preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1)) - preview_cards_requiring_review = preview_cards.filter_map do |preview_card| + 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? @@ -53,12 +47,6 @@ class Trends::Links < Trends::Base 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 @@ -67,6 +55,10 @@ class Trends::Links < Trends::Base PREFIX end + def klass + PreviewCard + end + private def calculate_scores(preview_cards, at_time) @@ -96,17 +88,27 @@ class Trends::Links < Trends::Base 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) + add_to_and_remove_from_subsets(preview_card.id, decaying_score, { + all: true, + allowed: preview_card.trendable?, + }) - if preview_card.trendable? - redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id) - else - redis.zrem("#{PREFIX}:allowed", preview_card.id) - end + next unless valid_locale?(preview_card.language) + + add_to_and_remove_from_subsets(preview_card.id, decaying_score, { + "all:#{preview_card.language}" => true, + "allowed:#{preview_card.language}" => preview_card.trendable?, + }) + end + + # Clean up localized sets by calculating the intersection with the main + # set. We do this instead of just deleting the localized sets to avoid + # having moments where the API returns empty results + + redis.pipelined do + Trends.available_locales.each do |locale| + redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max') + redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max') end end end diff --git a/app/models/trends/preview_card_batch.rb b/app/models/trends/preview_card_batch.rb new file mode 100644 index 000000000..b1d682910 --- /dev/null +++ b/app/models/trends/preview_card_batch.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Trends::PreviewCardBatch + include ActiveModel::Model + include Authorization + + attr_accessor :preview_card_ids, :action, :current_account, :precision + + def save + case action + when 'approve' + approve! + when 'approve_providers' + approve_providers! + when 'reject' + reject! + when 'reject_providers' + reject_providers! + 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, :review?) } + preview_cards.update_all(trendable: true) + end + + def approve_providers! + preview_card_providers.each do |provider| + authorize(provider, :review?) + 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, :review?) } + preview_cards.update_all(trendable: false) + end + + def reject_providers! + preview_card_providers.each do |provider| + authorize(provider, :review?) + 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/trends/preview_card_filter.rb b/app/models/trends/preview_card_filter.rb new file mode 100644 index 000000000..25add58c8 --- /dev/null +++ b/app/models/trends/preview_card_filter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Trends::PreviewCardFilter + KEYS = %i( + trending + locale + ).freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = PreviewCard.unscoped + + params.each do |key, value| + next if %w(page locale).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 'trending' + trending_scope(value) + else + raise "Unknown filter: #{key}" + end + end + + def trending_scope(value) + scope = Trends.links.query + + scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present? + scope = scope.allowed if value == 'allowed' + + scope.to_arel + end +end diff --git a/app/models/trends/preview_card_provider_batch.rb b/app/models/trends/preview_card_provider_batch.rb new file mode 100644 index 000000000..062720c81 --- /dev/null +++ b/app/models/trends/preview_card_provider_batch.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Trends::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, :review?) } + preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc) + end + + def reject! + preview_card_providers.each { |provider| authorize(provider, :review?) } + preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc) + end +end diff --git a/app/models/trends/preview_card_provider_filter.rb b/app/models/trends/preview_card_provider_filter.rb new file mode 100644 index 000000000..abfdd07e8 --- /dev/null +++ b/app/models/trends/preview_card_provider_filter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Trends::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/trends/query.rb b/app/models/trends/query.rb new file mode 100644 index 000000000..64a4c0c1f --- /dev/null +++ b/app/models/trends/query.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +class Trends::Query + include Redisable + include Enumerable + + attr_reader :prefix, :klass, :loaded + + alias loaded? loaded + + def initialize(prefix, klass) + @prefix = prefix + @klass = klass + @records = [] + @loaded = false + @allowed = false + @limit = -1 + @offset = 0 + end + + def allowed! + @allowed = true + self + end + + def allowed + clone.allowed! + end + + def in_locale!(value) + @locale = value + self + end + + def in_locale(value) + clone.in_locale!(value) + end + + def offset!(value) + @offset = value + self + end + + def offset(value) + clone.offset!(value) + end + + def limit!(value) + @limit = value + self + end + + def limit(value) + clone.limit!(value) + end + + def records + load + @records + end + + delegate :each, :empty?, :first, :last, to: :records + + def to_ary + records.dup + end + + alias to_a to_ary + + def to_arel + tmp_ids = ids + + if tmp_ids.empty? + klass.none + else + klass.joins("join unnest(array[#{tmp_ids.join(',')}]) with ordinality as x (id, ordering) on #{klass.table_name}.id = x.id").reorder('x.ordering') + end + end + + private + + def key + [@prefix, @allowed ? 'allowed' : 'all', @locale].compact.join(':') + end + + def load + unless loaded? + @records = perform_queries + @loaded = true + end + + self + end + + def ids + redis.zrevrange(key, @offset, @limit.positive? ? @limit - 1 : @limit).map(&:to_i) + end + + def perform_queries + apply_scopes(to_arel).to_a + end + + def apply_scopes(scope) + scope + end +end diff --git a/app/models/trends/status_batch.rb b/app/models/trends/status_batch.rb new file mode 100644 index 000000000..78d93bed4 --- /dev/null +++ b/app/models/trends/status_batch.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Trends::StatusBatch + include ActiveModel::Model + include Authorization + + attr_accessor :status_ids, :action, :current_account + + def save + case action + when 'approve' + approve! + when 'approve_accounts' + approve_accounts! + when 'reject' + reject! + when 'reject_accounts' + reject_accounts! + end + end + + private + + def statuses + @statuses ||= Status.where(id: status_ids) + end + + def status_accounts + @status_accounts ||= Account.where(id: statuses.map(&:account_id).uniq) + end + + def approve! + statuses.each { |status| authorize(status, :review?) } + statuses.update_all(trendable: true) + end + + def approve_accounts! + status_accounts.each do |account| + authorize(account, :review?) + account.update(trendable: true, reviewed_at: action_time) + end + + # Reset any individual overrides + statuses.update_all(trendable: nil) + end + + def reject! + statuses.each { |status| authorize(status, :review?) } + statuses.update_all(trendable: false) + end + + def reject_accounts! + status_accounts.each do |account| + authorize(account, :review?) + account.update(trendable: false, reviewed_at: action_time) + end + + # Reset any individual overrides + statuses.update_all(trendable: nil) + end + + def action_time + @action_time ||= Time.now.utc + end +end diff --git a/app/models/trends/status_filter.rb b/app/models/trends/status_filter.rb new file mode 100644 index 000000000..7c453e339 --- /dev/null +++ b/app/models/trends/status_filter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Trends::StatusFilter + KEYS = %i( + trending + locale + ).freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = Status.unscoped.kept + + params.each do |key, value| + next if %w(page locale).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 'trending' + trending_scope(value) + else + raise "Unknown filter: #{key}" + end + end + + def trending_scope(value) + scope = Trends.statuses.query + + scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present? + scope = scope.allowed if value == 'allowed' + + scope.to_arel + end +end diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb new file mode 100644 index 000000000..e785413ec --- /dev/null +++ b/app/models/trends/statuses.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +class Trends::Statuses < Trends::Base + PREFIX = 'trending_statuses' + + self.default_options = { + threshold: 5, + review_threshold: 3, + score_halflife: 2.hours.freeze, + } + + class Query < Trends::Query + def filtered_for!(account) + @account = account + self + end + + def filtered_for(account) + clone.filtered_for!(account) + end + + private + + def apply_scopes(scope) + scope.includes(:account) + end + + def perform_queries + return super if @account.nil? + + statuses = super + account_ids = statuses.map(&:account_id) + account_domains = statuses.map(&:account_domain) + + preloaded_relations = { + blocking: Account.blocking_map(account_ids, @account.id), + blocked_by: Account.blocked_by_map(account_ids, @account.id), + muting: Account.muting_map(account_ids, @account.id), + following: Account.following_map(account_ids, @account.id), + domain_blocking_by_domain: Account.domain_blocking_map_by_domain(account_domains, @account.id), + } + + statuses.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? } + end + end + + def register(status, at_time = Time.now.utc) + add(status.proper, status.account_id, at_time) if eligible?(status) + end + + def add(status, _account_id, at_time = Time.now.utc) + # We rely on the total reblogs and favourites count, so we + # don't record which account did the what and when here + + record_used_id(status.id, at_time) + end + + def query + Query.new(key_prefix, klass) + end + + def refresh(at_time = Time.now.utc) + statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments) + calculate_scores(statuses, at_time) + trim_older_items + end + + def request_review + statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account) + + statuses.filter_map do |status| + next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification? + + status.account.touch(:requested_review_at) + status + end + end + + protected + + def key_prefix + PREFIX + end + + def klass + Status + end + + private + + def eligible?(status) + original_status = status.proper + + original_status.public_visibility? && + original_status.account.discoverable? && !original_status.account.silenced? && + original_status.spoiler_text.blank? && !original_status.sensitive? && !original_status.reply? + end + + def calculate_scores(statuses, at_time) + redis.pipelined do + statuses.each do |status| + expected = 1.0 + observed = (status.reblogs_count + status.favourites_count).to_f + + score = begin + if expected > observed || observed < options[:threshold] + 0 + else + ((observed - expected)**2) / expected + end + end + + decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f)) + + add_to_and_remove_from_subsets(status.id, decaying_score, { + all: true, + allowed: status.trendable? && status.account.discoverable?, + }) + + next unless valid_locale?(status.language) + + add_to_and_remove_from_subsets(status.id, decaying_score, { + "all:#{status.language}" => true, + "allowed:#{status.language}" => status.trendable? && status.account.discoverable?, + }) + end + + # Clean up localized sets by calculating the intersection with the main + # set. We do this instead of just deleting the localized sets to avoid + # having moments where the API returns empty results + + Trends.available_locales.each do |locale| + redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max') + redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max') + 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/tag_batch.rb b/app/models/trends/tag_batch.rb new file mode 100644 index 000000000..16ee08c06 --- /dev/null +++ b/app/models/trends/tag_batch.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Trends::TagBatch + include ActiveModel::Model + include Authorization + + attr_accessor :tag_ids, :action, :current_account + + def save + case action + when 'approve' + approve! + when 'reject' + reject! + end + end + + private + + def tags + Tag.where(id: tag_ids) + end + + def approve! + tags.each { |tag| authorize(tag, :review?) } + tags.update_all(trendable: true, reviewed_at: action_time) + end + + def reject! + tags.each { |tag| authorize(tag, :review?) } + 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/trends/tag_filter.rb b/app/models/trends/tag_filter.rb new file mode 100644 index 000000000..3b142efc4 --- /dev/null +++ b/app/models/trends/tag_filter.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Trends::TagFilter + KEYS = %i( + trending + status + ).freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + 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' + + 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 'status' + status_scope(value) + else + raise "Unknown filter: #{key}" + end + end + + def trending_scope + Trends.tags.query.to_arel + 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/trends/tags.rb b/app/models/trends/tags.rb index 2ea4550df..3caa58815 100644 --- a/app/models/trends/tags.rb +++ b/app/models/trends/tags.rb @@ -5,7 +5,7 @@ class Trends::Tags < Trends::Base self.default_options = { threshold: 5, - review_threshold: 10, + review_threshold: 3, max_score_cooldown: 2.days.freeze, max_score_halflife: 4.hours.freeze, } @@ -29,27 +29,15 @@ class Trends::Tags < Trends::Base 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| + 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 @@ -58,6 +46,10 @@ class Trends::Tags < Trends::Base PREFIX end + def klass + Tag + end + private def calculate_scores(tags, at_time) @@ -87,18 +79,10 @@ class Trends::Tags < Trends::Base 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 + add_to_and_remove_from_subsets(tag.id, decaying_score, { + all: true, + allowed: tag.trendable?, + }) end end diff --git a/app/models/user.rb b/app/models/user.rb index 517254a91..bbf850d84 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -269,7 +269,7 @@ class User < ApplicationRecord settings.notification_emails['appeal'] end - def allows_trending_tag_emails? + def allows_trends_review_emails? settings.notification_emails['trending_tag'] end diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 46237e45c..cc23771e7 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy def unblock_email? staff? end + + def review? + staff? + end end diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb index 4f485d7fc..0410987e4 100644 --- a/app/policies/preview_card_policy.rb +++ b/app/policies/preview_card_policy.rb @@ -5,7 +5,7 @@ class PreviewCardPolicy < ApplicationPolicy staff? end - def update? + def review? staff? end end diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb index 598d54a5e..44d2ad5cf 100644 --- a/app/policies/preview_card_provider_policy.rb +++ b/app/policies/preview_card_provider_policy.rb @@ -5,7 +5,7 @@ class PreviewCardProviderPolicy < ApplicationPolicy staff? end - def update? + def review? staff? end end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 6e9b840db..400f1ec79 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -41,6 +41,10 @@ class StatusPolicy < ApplicationPolicy staff? || owned? end + def review? + staff? + end + private def requires_mention? diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb index aaf70fcab..bdfcec0c9 100644 --- a/app/policies/tag_policy.rb +++ b/app/policies/tag_policy.rb @@ -12,4 +12,8 @@ class TagPolicy < ApplicationPolicy def update? staff? end + + def review? + staff? + end end diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index a572a7c59..a2d535d26 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -220,21 +220,23 @@ class DeleteAccountService < BaseService return unless keep_account_record? - @account.silenced_at = nil - @account.suspended_at = @options[:suspended_at] || Time.now.utc - @account.suspension_origin = :local - @account.locked = false - @account.memorial = false - @account.discoverable = false - @account.display_name = '' - @account.note = '' - @account.fields = [] - @account.statuses_count = 0 - @account.followers_count = 0 - @account.following_count = 0 - @account.moved_to_account = nil - @account.also_known_as = [] - @account.trust_level = :untrusted + @account.silenced_at = nil + @account.suspended_at = @options[:suspended_at] || Time.now.utc + @account.suspension_origin = :local + @account.locked = false + @account.memorial = false + @account.discoverable = false + @account.trendable = false + @account.display_name = '' + @account.note = '' + @account.fields = [] + @account.statuses_count = 0 + @account.followers_count = 0 + @account.following_count = 0 + @account.moved_to_account = nil + @account.reviewed_at = nil + @account.requested_review_at = nil + @account.also_known_as = [] @account.avatar.destroy @account.header.destroy @account.save! diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index a0ab3b4b7..0ca0081b4 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -17,6 +17,8 @@ class FavouriteService < BaseService favourite = Favourite.create!(account: account, status: status) + Trends.statuses.register(status) + create_notification(favourite) bump_potential_friendship(account, status) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 2d1265f10..7d2981709 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -30,8 +30,7 @@ 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) + Trends.register!(reblog) DistributionWorker.perform_async(reblog.id) ActivityPub::DistributionWorker.perform_async(reblog.id) diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index 526c844e9..41f3975cf 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -3,7 +3,7 @@ = f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id .batch-table__row__content.batch-table__row__content--with-image .batch-table__row__content__image - = custom_emoji_tag(custom_emoji, animate = current_account&.user&.setting_auto_play_gif) + = custom_emoji_tag(custom_emoji, current_account&.user&.setting_auto_play_gif) .batch-table__row__content__text %samp= ":#{custom_emoji.shortcode}:" diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml index 272681864..ebc4a2c6b 100644 --- a/app/views/admin/follow_recommendations/show.html.haml +++ b/app/views/admin/follow_recommendations/show.html.haml @@ -9,12 +9,14 @@ %hr.spacer/ = form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do + - RelationshipFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + .filters .filter-subset.filter-subset--with-select %strong= t('admin.follow_recommendations.language') .input.select.optional - = select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [standard_locale_name(key), key]}, @language) - + = select_tag :language, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, @language) .filter-subset %strong= t('admin.follow_recommendations.status') %ul diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml index 240ae722b..79f3513d3 100644 --- a/app/views/admin/trends/links/index.html.haml +++ b/app/views/admin/trends/links/index.html.haml @@ -4,23 +4,29 @@ - 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' += form_tag admin_trends_links_path, method: 'GET', class: 'simple_form' do + - Trends::PreviewCardFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? -%hr.spacer/ + .filters + .filter-subset.filter-subset--with-select + %strong= t('admin.follow_recommendations.language') + .input.select.optional + = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true + .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' = form_for(@form, url: batch_admin_trends_links_path) do |f| = hidden_field_tag :page, params[:page] || 1 - - PreviewCardFilter::KEYS.each do |key| + - Trends::PreviewCardFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? .batch-table @@ -29,9 +35,9 @@ = 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('check'), t('admin.trends.links.allow_provider')]), name: :approve_providers, 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') } + = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_providers, 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' 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 index eac6e641f..b79349947 100644 --- a/app/views/admin/trends/links/preview_card_providers/index.html.haml +++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml @@ -23,7 +23,7 @@ = 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| + - Trends::PreviewCardProviderFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? .batch-table.optional diff --git a/app/views/admin/trends/statuses/_status.html.haml b/app/views/admin/trends/statuses/_status.html.haml new file mode 100644 index 000000000..c99ee5d60 --- /dev/null +++ b/app/views/admin/trends/statuses/_status.html.haml @@ -0,0 +1,30 @@ +.batch-table__row{ class: [status.account.requires_review? && 'batch-table__row--attention', !status.account.requires_review? && !status.trendable? && 'batch-table__row--muted'] } + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id + + .batch-table__row__content.pending-account__header + .one-liner + = admin_account_link_to status.account + + = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', class: 'emojify', rel: 'noopener noreferrer' do + = one_line_preview(status) + + - status.media_attachments.each do |media_attachment| + %abbr{ title: media_attachment.description } + = fa_icon 'link' + = media_attachment.file_file_name + + = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count)) + + - if status.account.domain.present? + • + = status.account.domain + - if status.language.present? + • + = standard_locale_name(status.language) + - if status.trendable? && (rank = Trends.statuses.rank(status.id)) + • + %abbr{ title: t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) + - elsif status.account.requires_review? + • + = t('admin.trends.pending_review') diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml new file mode 100644 index 000000000..347688262 --- /dev/null +++ b/app/views/admin/trends/statuses/index.html.haml @@ -0,0 +1,43 @@ +- content_for :page_title do + = t('admin.trends.statuses.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + += form_tag admin_trends_statuses_path, method: 'GET', class: 'simple_form' do + - Trends::StatusFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .filters + .filter-subset.filter-subset--with-select + %strong= t('admin.follow_recommendations.language') + .input.select.optional + = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key]}, params[:locale]), include_blank: true + .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' + += form_for(@form, url: batch_admin_trends_statuses_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - Trends::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('check'), t('admin.trends.statuses.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.statuses.allow_account')]), name: :approve_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.statuses.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.statuses.disallow_account')]), name: :reject_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + .batch-table__body + - if @statuses.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'status', collection: @statuses, locals: { f: f } + += paginate @statuses diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml index 8df0a9920..8a2f785bc 100644 --- a/app/views/admin/trends/tags/index.html.haml +++ b/app/views/admin/trends/tags/index.html.haml @@ -13,12 +13,10 @@ %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| + - Trends::TagFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? .batch-table.optional 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..405926fdd --- /dev/null +++ b/app/views/admin_mailer/_new_trending_links.text.erb @@ -0,0 +1,14 @@ +<%= raw t('admin_mailer.new_trends.new_trending_links.title') %> + +<% @links.each do |link| %> +- <%= link.title %> • <%= link.url %> + <%= raw 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 %> +<%= raw t('admin_mailer.new_trends.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2), rank: Trends.links.options[:review_threshold]) %> +<% else %> +<%= raw t('admin_mailer.new_trends.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_statuses.text.erb b/app/views/admin_mailer/_new_trending_statuses.text.erb new file mode 100644 index 000000000..8d11a80c2 --- /dev/null +++ b/app/views/admin_mailer/_new_trending_statuses.text.erb @@ -0,0 +1,14 @@ +<%= raw t('admin_mailer.new_trends.new_trending_statuses.title') %> + +<% @statuses.each do |status| %> +- <%= ActivityPub::TagManager.instance.url_for(status) %> + <%= raw t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id).round(2)) %> +<% end %> + +<% if @lowest_trending_status %> +<%= raw t('admin_mailer.new_trends.new_trending_statuses.requirements', lowest_status_url: ActivityPub::TagManager.instance.url_for(@lowest_trending_status), lowest_status_score: Trends.statuses.score(@lowest_trending_status.id).round(2), rank: Trends.statuses.options[:review_threshold]) %> +<% else %> +<%= raw t('admin_mailer.new_trends.new_trending_statuses.no_approved_statuses') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %> 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..49fe84309 --- /dev/null +++ b/app/views/admin_mailer/_new_trending_tags.text.erb @@ -0,0 +1,14 @@ +<%= raw t('admin_mailer.new_trends.new_trending_tags.title') %> + +<% @tags.each do |tag| %> +- #<%= tag.name %> + <%= raw 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 %> +<%= raw t('admin_mailer.new_trends.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2), rank: Trends.tags.options[:review_threshold]) %> +<% else %> +<%= raw t('admin_mailer.new_trends.new_trending_tags.no_approved_tags') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(pending_review: '1') %> diff --git a/app/views/admin_mailer/new_trending_links.text.erb b/app/views/admin_mailer/new_trending_links.text.erb deleted file mode 100644 index 51789aca5..000000000 --- a/app/views/admin_mailer/new_trending_links.text.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%= 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_tags.text.erb b/app/views/admin_mailer/new_trending_tags.text.erb deleted file mode 100644 index 9ea31fa7c..000000000 --- a/app/views/admin_mailer/new_trending_tags.text.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%= 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(status: 'pending_review') %> diff --git a/app/views/admin_mailer/new_trends.text.erb b/app/views/admin_mailer/new_trends.text.erb new file mode 100644 index 000000000..13b296846 --- /dev/null +++ b/app/views/admin_mailer/new_trends.text.erb @@ -0,0 +1,13 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_trends.body') %> + +<% unless @links.empty? %> +<%= render 'new_trending_links' %> +<% end %> +<% unless @tags.empty? %> +<%= render 'new_trending_tags' unless @tags.empty? %> +<% end %> +<% unless @statuses.empty? %> +<%= render 'new_trending_statuses' unless @statuses.empty? %> +<% end %> diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml index 6826c3b58..e97c493fe 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 = Trends.tags.get(true, 3) + - trends = Trends.tags.query.allowed.limit(3) - unless trends.empty? .endorsements-widget.trends-widget diff --git a/app/workers/scheduler/follow_recommendations_scheduler.rb b/app/workers/scheduler/follow_recommendations_scheduler.rb index 084619cbd..57f78170e 100644 --- a/app/workers/scheduler/follow_recommendations_scheduler.rb +++ b/app/workers/scheduler/follow_recommendations_scheduler.rb @@ -18,7 +18,7 @@ class Scheduler::FollowRecommendationsScheduler fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE) - I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq.each do |locale| + Trends.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).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.account_id, recommendation.rank] } @@ -49,11 +49,11 @@ class Scheduler::FollowRecommendationsScheduler end end - redis.pipelined do - redis.del(key(locale)) + redis.multi do |multi| + multi.del(key(locale)) recommendations.each do |(account_id, rank)| - redis.zadd(key(locale), rank, account_id) + multi.zadd(key(locale), rank, account_id) end end end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 6ffe12ae0..c24146da4 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -7,7 +7,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/status.rb", - "line": 104, + "line": 105, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")", "render_path": null, @@ -20,6 +20,26 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "30dfe36e87fe1b8f239df9a33d576e44a9863f73b680198d4713be6540ae61d3", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/trends/query.rb", + "line": 60, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "klass.joins(\"join unnest(array[#{ids.join(\",\")}]) with ordinality as x (id, ordering) on #{klass.table_name}.id = x.id\")", + "render_path": null, + "location": { + "type": "method", + "class": "Trends::Query", + "method": "to_arel" + }, + "user_input": "ids.join(\",\")", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Redirect", "warning_code": 18, @@ -100,26 +120,6 @@ "confidence": "High", "note": "" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "8c1d8c4b76c1cd3960e90dff999f854a6ff742fcfd8de6c7184ac5a1b1a4d7dd", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/preview_card_filter.rb", - "line": 50, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "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": "PreviewCardFilter", - "method": "trending_scope" - }, - "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": "Cross-Site Scripting", "warning_code": 2, @@ -134,7 +134,7 @@ { "type": "template", "name": "admin/disputes/appeals/index", - "line": 16, + "line": 20, "file": "app/views/admin/disputes/appeals/index.html.haml", "rendered": { "name": "admin/disputes/appeals/_appeal", @@ -170,26 +170,6 @@ "confidence": "High", "note": "" }, - { - "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": "TagFilter", - "method": "trending_scope" - }, - "user_input": "Trends.tags.currently_trending_ids(false, -1).map(&:to_i).join(\",\")", - "confidence": "Medium", - "note": "" - }, { "warning_type": "Cross-Site Scripting", "warning_code": 4, @@ -204,7 +184,7 @@ { "type": "template", "name": "admin/trends/links/index", - "line": 39, + "line": 45, "file": "app/views/admin/trends/links/index.html.haml", "rendered": { "name": "admin/trends/links/_preview_card", @@ -241,6 +221,6 @@ "note": "" } ], - "updated": "2022-02-13 02:24:12 +0100", + "updated": "2022-02-15 03:48:53 +0100", "brakeman_version": "5.2.1" } diff --git a/config/locales/en.yml b/config/locales/en.yml index f045174a9..60c291540 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -787,6 +787,15 @@ en: rejected: Links from this publisher won't trend title: Publishers rejected: Rejected + statuses: + allow: Allow post + allow_account: Allow author + disallow: Disallow post + disallow_account: Disallow author + shared_by: + one: Shared or favourited one time + other: Shared and favourited %{friendly_count} times + title: Trending posts tags: current_score: Current score %{score} dashboard: @@ -835,16 +844,21 @@ en: body: "%{reporter} has reported %{target}" body_remote: Someone from %{domain} has reported %{target} subject: New report for %{instance} (#%{id}) - 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} + new_trends: + body: 'The following items need a review before they can be displayed publicly:' + new_trending_links: + no_approved_links: There are currently no approved trending links. + requirements: 'Any of these candidates could surpass the #%{rank} approved trending link, which is currently "%{lowest_link_title}" with a score of %{lowest_link_score}.' + title: Trending links + new_trending_statuses: + no_approved_statuses: There are currently no approved trending posts. + requirements: 'Any of these candidates could surpass the #%{rank} approved trending post, which is currently %{lowest_status_url} with a score of %{lowest_status_score}.' + title: Trending posts + new_trending_tags: + no_approved_tags: There are currently no approved trending hashtags. + requirements: 'Any of these candidates could surpass the #%{rank} approved trending hashtag, which is currently #%{lowest_tag_name} with a score of %{lowest_tag_score}.' + title: Trending hashtags + subject: New trends 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. diff --git a/config/navigation.rb b/config/navigation.rb index 3fc3747d5..620f78c57 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -34,6 +34,7 @@ SimpleNavigation::Configuration.run do |navigation| 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 :statuses, safe_join([fa_icon('comments-o fw'), t('admin.trends.statuses.title')]), admin_trends_statuses_path, highlights_on: %r{/admin/trends/statuses} 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 diff --git a/config/routes.rb b/config/routes.rb index 176438e45..a820f32ad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -327,6 +327,12 @@ Rails.application.routes.draw do end end + resources :statuses, only: [:index] do + collection do + post :batch + end + end + namespace :links do resources :preview_card_providers, only: [:index], path: :publishers do collection do @@ -448,6 +454,7 @@ Rails.application.routes.draw do namespace :trends do resources :links, only: [:index] resources :tags, only: [:index] + resources :statuses, only: [:index] end namespace :emails do @@ -554,6 +561,8 @@ Rails.application.routes.draw do namespace :trends do resources :tags, only: [:index] + resources :links, only: [:index] + resources :statuses, only: [:index] end post :measures, to: 'measures#create' diff --git a/db/migrate/20220202200743_add_trendable_to_accounts.rb b/db/migrate/20220202200743_add_trendable_to_accounts.rb new file mode 100644 index 000000000..414df5108 --- /dev/null +++ b/db/migrate/20220202200743_add_trendable_to_accounts.rb @@ -0,0 +1,7 @@ +class AddTrendableToAccounts < ActiveRecord::Migration[6.1] + def change + add_column :accounts, :trendable, :boolean + add_column :accounts, :reviewed_at, :datetime + add_column :accounts, :requested_review_at, :datetime + end +end diff --git a/db/migrate/20220202200926_add_trendable_to_statuses.rb b/db/migrate/20220202200926_add_trendable_to_statuses.rb new file mode 100644 index 000000000..7f38c8ca7 --- /dev/null +++ b/db/migrate/20220202200926_add_trendable_to_statuses.rb @@ -0,0 +1,5 @@ +class AddTrendableToStatuses < ActiveRecord::Migration[6.1] + def change + add_column :statuses, :trendable, :boolean + end +end diff --git a/db/post_migrate/20220202201015_remove_trust_level_from_accounts.rb b/db/post_migrate/20220202201015_remove_trust_level_from_accounts.rb new file mode 100644 index 000000000..d5d995ece --- /dev/null +++ b/db/post_migrate/20220202201015_remove_trust_level_from_accounts.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemoveTrustLevelFromAccounts < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured { remove_column :accounts, :trust_level, :integer } + end +end diff --git a/db/schema.rb b/db/schema.rb index 0e9b6e619..e54de5b37 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -177,13 +177,15 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do t.string "also_known_as", array: true t.datetime "silenced_at" t.datetime "suspended_at" - t.integer "trust_level" t.boolean "hide_collections" t.integer "avatar_storage_schema_version" t.integer "header_storage_schema_version" t.string "devices_url" t.integer "suspension_origin" t.datetime "sensitized_at" + t.boolean "trendable" + t.datetime "reviewed_at" + t.datetime "requested_review_at" t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id" @@ -887,6 +889,7 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do t.bigint "poll_id" t.datetime "deleted_at" t.datetime "edited_at" + t.boolean "trendable" 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)))" @@ -1228,5 +1231,4 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do ORDER BY (sum(t0.rank)) DESC; SQL add_index "follow_recommendations", ["account_id"], name: "index_follow_recommendations_on_account_id", unique: true - end diff --git a/spec/controllers/api/v1/trends/tags_controller_spec.rb b/spec/controllers/api/v1/trends/tags_controller_spec.rb index e2e26dcab..d29551c56 100644 --- a/spec/controllers/api/v1/trends/tags_controller_spec.rb +++ b/spec/controllers/api/v1/trends/tags_controller_spec.rb @@ -7,10 +7,9 @@ RSpec.describe Api::V1::Trends::TagsController, type: :controller do 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) + Fabricate.times(10, :tag).each do |tag| + 10.times { |i| Trends.tags.add(tag, i) } + end get :index end diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index 9c0372b47..01436ba7a 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -6,14 +6,9 @@ class AdminMailerPreview < ActionMailer::Preview 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)) + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends + def new_trends + AdminMailer.new_trends(Account.first, PreviewCard.limit(3), Tag.limit(3), Status.where(reblog_of_id: nil).limit(3)) end # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal diff --git a/spec/models/trends/statuses_spec.rb b/spec/models/trends/statuses_spec.rb new file mode 100644 index 000000000..9cc67acbe --- /dev/null +++ b/spec/models/trends/statuses_spec.rb @@ -0,0 +1,110 @@ +require 'rails_helper' + +RSpec.describe Trends::Statuses do + subject! { described_class.new(threshold: 5, review_threshold: 10, score_halflife: 8.hours) } + + let!(:at_time) { DateTime.new(2021, 11, 14, 10, 15, 0) } + + describe 'Trends::Statuses::Query' do + let!(:query) { subject.query } + let!(:today) { at_time } + + let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: today) } + let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) } + + before do + 15.times { reblog(status1, today) } + 12.times { reblog(status2, today) } + + subject.refresh(today) + end + + describe '#filtered_for' do + let(:account) { Fabricate(:account) } + + it 'returns a composable query scope' do + expect(query.filtered_for(account)).to be_a Trends::Query + end + + it 'filters out blocked accounts' do + account.block!(status1.account) + expect(query.filtered_for(account).to_a).to eq [status2] + end + + it 'filters out muted accounts' do + account.mute!(status2.account) + expect(query.filtered_for(account).to_a).to eq [status1] + end + + it 'filters out blocked-by accounts' do + status1.account.block!(account) + expect(query.filtered_for(account).to_a).to eq [status2] + end + end + end + + describe '#add' do + let(:status) { Fabricate(:status) } + + before do + subject.add(status, 1, at_time) + end + + it 'records use' do + expect(subject.send(:recently_used_ids, at_time)).to eq [status.id] + end + end + + describe '#query' do + it 'returns a composable query scope' do + expect(subject.query).to be_a Trends::Query + end + + it 'responds to filtered_for' do + expect(subject.query).to respond_to(:filtered_for) + end + end + + describe '#refresh' do + let!(:today) { at_time } + let!(:yesterday) { today - 1.day } + + let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: yesterday) } + let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) } + let!(:status3) { Fabricate(:status, text: 'Baz', trendable: true, created_at: today) } + + before do + 13.times { reblog(status1, today) } + 13.times { reblog(status2, today) } + 4.times { reblog(status3, today) } + end + + context do + before do + subject.refresh(today) + end + + it 'calculates and re-calculates scores' do + expect(subject.query.limit(10).to_a).to eq [status2, status1] + end + + it 'omits statuses below threshold' do + expect(subject.query.limit(10).to_a).to_not include(status3) + end + end + + it 'decays scores' do + subject.refresh(today) + original_score = subject.score(status2.id) + expect(original_score).to be_a Float + subject.refresh(today + subject.options[:score_halflife]) + decayed_score = subject.score(status2.id) + expect(decayed_score).to be <= original_score / 2 + end + end + + def reblog(status, at_time) + reblog = Fabricate(:status, reblog: status, created_at: at_time) + subject.add(status, reblog.account_id, at_time) + end +end diff --git a/spec/models/trends/tags_spec.rb b/spec/models/trends/tags_spec.rb index 4f98c6aa4..f48c73503 100644 --- a/spec/models/trends/tags_spec.rb +++ b/spec/models/trends/tags_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Trends::Tags do end end - describe '#get' do + describe '#query' do pending end @@ -47,11 +47,11 @@ RSpec.describe Trends::Tags do end it 'calculates and re-calculates scores' do - expect(subject.get(false, 10)).to eq [tag1, tag3] + expect(subject.query.limit(10).to_a).to eq [tag1, tag3] end it 'omits hashtags below threshold' do - expect(subject.get(false, 10)).to_not include(tag2) + expect(subject.query.limit(10).to_a).to_not include(tag2) end end -- cgit From 3d60708508c6bfc5b6635aff0482d640a5f318ca Mon Sep 17 00:00:00 2001 From: Claire Date: Sat, 26 Feb 2022 16:28:28 +0100 Subject: Fix crash in EmailDomainBlockRefreshScheduler (#17649) --- app/workers/scheduler/email_domain_block_refresh_scheduler.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'app/workers') diff --git a/app/workers/scheduler/email_domain_block_refresh_scheduler.rb b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb index c67be6843..e0ad89866 100644 --- a/app/workers/scheduler/email_domain_block_refresh_scheduler.rb +++ b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb @@ -15,7 +15,8 @@ class Scheduler::EmailDomainBlockRefreshScheduler if ip?(email_domain_block.domain) [email_domain_block.domain] else - dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::A).to_a + dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::AAAA).to_a.map { |resource| resource.address.to_s } + resources = dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::A).to_a + dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::AAAA).to_a + resources.map { |resource| resource.address.to_s } end end -- cgit From 50ea54b3ed125477656893a67d9f552bb53e8ba5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 1 Mar 2022 16:48:58 +0100 Subject: Change authorized applications page (#17656) * Change authorized applications page * Hide revoke button for superapps and suspended accounts * Clean up db/schema.rb --- app/controllers/api/base_controller.rb | 1 + .../concerns/access_token_tracking_concern.rb | 21 +++++ .../concerns/session_tracking_concern.rb | 4 +- app/controllers/concerns/user_tracking_concern.rb | 4 +- app/helpers/application_helper.rb | 15 ++++ app/javascript/styles/mastodon/admin.scss | 10 +++ app/javascript/styles/mastodon/containers.scss | 13 ++-- app/javascript/styles/mastodon/forms.scss | 71 ++++++++++++++++- app/lib/access_token_extension.rb | 4 + app/lib/application_extension.rb | 4 + app/lib/scope_parser.rb | 10 +++ app/lib/scope_transformer.rb | 40 ++++++++++ app/views/layouts/modal.html.haml | 3 +- app/views/oauth/authorizations/new.html.haml | 50 +++++++----- .../oauth/authorized_applications/index.html.haml | 62 ++++++++++----- app/workers/scheduler/ip_cleanup_scheduler.rb | 1 + config/locales/doorkeeper.en.yml | 43 +++++++++-- ...1951_add_last_used_at_to_oauth_access_tokens.rb | 6 ++ db/schema.rb | 4 +- spec/lib/scope_transformer_spec.rb | 89 ++++++++++++++++++++++ 20 files changed, 393 insertions(+), 62 deletions(-) create mode 100644 app/controllers/concerns/access_token_tracking_concern.rb create mode 100644 app/lib/scope_parser.rb create mode 100644 app/lib/scope_transformer.rb create mode 100644 db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb create mode 100644 spec/lib/scope_transformer_spec.rb (limited to 'app/workers') diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index b863d8643..72c30dec7 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -5,6 +5,7 @@ class Api::BaseController < ApplicationController DEFAULT_ACCOUNTS_LIMIT = 40 include RateLimitHeaders + include AccessTokenTrackingConcern skip_before_action :store_current_location skip_before_action :require_functional!, unless: :whitelist_mode? diff --git a/app/controllers/concerns/access_token_tracking_concern.rb b/app/controllers/concerns/access_token_tracking_concern.rb new file mode 100644 index 000000000..cf60cfb99 --- /dev/null +++ b/app/controllers/concerns/access_token_tracking_concern.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module AccessTokenTrackingConcern + extend ActiveSupport::Concern + + ACCESS_TOKEN_UPDATE_FREQUENCY = 24.hours.freeze + + included do + before_action :update_access_token_last_used + end + + private + + def update_access_token_last_used + doorkeeper_token.update_last_used(request) if access_token_needs_update? + end + + def access_token_needs_update? + doorkeeper_token.present? && (doorkeeper_token.last_used_at.nil? || doorkeeper_token.last_used_at < ACCESS_TOKEN_UPDATE_FREQUENCY.ago) + end +end diff --git a/app/controllers/concerns/session_tracking_concern.rb b/app/controllers/concerns/session_tracking_concern.rb index 45361b019..eaaa4ac59 100644 --- a/app/controllers/concerns/session_tracking_concern.rb +++ b/app/controllers/concerns/session_tracking_concern.rb @@ -3,7 +3,7 @@ module SessionTrackingConcern extend ActiveSupport::Concern - UPDATE_SIGN_IN_HOURS = 24 + SESSION_UPDATE_FREQUENCY = 24.hours.freeze included do before_action :set_session_activity @@ -17,6 +17,6 @@ module SessionTrackingConcern end def session_needs_update? - !current_session.nil? && current_session.updated_at < UPDATE_SIGN_IN_HOURS.hours.ago + !current_session.nil? && current_session.updated_at < SESSION_UPDATE_FREQUENCY.ago end end diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index 45f3aab0d..e960cce53 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_FREQUENCY = 24.hours.freeze + SIGN_IN_UPDATE_FREQUENCY = 24.hours.freeze included do before_action :update_user_sign_in @@ -16,6 +16,6 @@ module UserTrackingConcern 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_FREQUENCY.ago) + user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < SIGN_IN_UPDATE_FREQUENCY.ago) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 36c66b7d1..c5d9bbc19 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -224,4 +224,19 @@ module ApplicationHelper content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json') # rubocop:enable Rails/OutputSafety end + + def grouped_scopes(scopes) + scope_parser = ScopeParser.new + scope_transformer = ScopeTransformer.new + + scopes.each_with_object({}) do |str, h| + scope = scope_transformer.apply(scope_parser.parse(str)) + + if h[scope.key] + h[scope.key].merge!(scope) + else + h[scope.key] = scope + end + end.values + end end diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 2e212eca5..f49a354dc 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -907,6 +907,12 @@ a.name-tag, text-decoration: none; margin-bottom: 10px; + .account-role { + vertical-align: middle; + } + } + + a.announcements-list__item__title { &:hover, &:focus, &:active { @@ -925,6 +931,10 @@ a.name-tag, align-items: center; } + &__permissions { + margin-top: 10px; + } + &:last-child { border-bottom: 0; } diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index e40ad18ff..a180df437 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -1,7 +1,6 @@ .container-alt { width: 700px; margin: 0 auto; - margin-top: 40px; @media screen and (max-width: 740px) { width: 100%; @@ -67,22 +66,20 @@ line-height: 18px; box-sizing: border-box; padding: 20px 0; - padding-bottom: 0; - margin-bottom: -30px; margin-top: 40px; + margin-bottom: 10px; + border-bottom: 1px solid $ui-base-color; @media screen and (max-width: 440px) { width: 100%; margin: 0; - margin-bottom: 10px; padding: 20px; - padding-bottom: 0; } .avatar { width: 40px; height: 40px; - margin-right: 8px; + margin-right: 10px; img { width: 100%; @@ -96,7 +93,7 @@ .name { flex: 1 1 auto; color: $secondary-text-color; - width: calc(100% - 88px); + width: calc(100% - 90px); .username { display: block; @@ -110,7 +107,7 @@ display: block; font-size: 32px; line-height: 40px; - margin-left: 8px; + margin-left: 10px; } } diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 65f53471d..6e02e2332 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -800,9 +800,41 @@ code { } } } +} - @media screen and (max-width: 740px) and (min-width: 441px) { - margin-top: 40px; +.oauth-prompt { + h3 { + color: $ui-secondary-color; + font-size: 17px; + line-height: 22px; + font-weight: 500; + margin-bottom: 30px; + } + + p { + font-size: 14px; + line-height: 18px; + margin-bottom: 30px; + } + + .permissions-list { + border: 1px solid $ui-base-color; + border-radius: 4px; + background: darken($ui-base-color, 4%); + margin-bottom: 30px; + } + + .actions { + margin: 0 -10px; + display: flex; + + form { + box-sizing: border-box; + padding: 0 10px; + flex: 1 1 auto; + min-height: 1px; + width: 50%; + } } } @@ -1005,3 +1037,38 @@ code { display: none; } } + +.permissions-list { + &__item { + padding: 15px; + color: $ui-secondary-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + display: flex; + align-items: center; + + &__text { + flex: 1 1 auto; + + &__title { + font-weight: 500; + } + + &__type { + color: $darker-text-color; + } + } + + &__icon { + flex: 0 0 auto; + font-size: 18px; + width: 30px; + color: $valid-value-color; + display: flex; + align-items: center; + } + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/app/lib/access_token_extension.rb b/app/lib/access_token_extension.rb index 3e184e775..2cafaaa20 100644 --- a/app/lib/access_token_extension.rb +++ b/app/lib/access_token_extension.rb @@ -11,6 +11,10 @@ module AccessTokenExtension update(revoked_at: clock.now.utc) end + def update_last_used(request, clock = Time) + update(last_used_at: clock.now.utc, last_used_ip: request.remote_ip) + end + def push_to_streaming_api Redis.current.publish("timeline:access_token:#{id}", Oj.dump(event: :kill)) if revoked? || destroyed? end diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb index e61cd0721..a1fea6430 100644 --- a/app/lib/application_extension.rb +++ b/app/lib/application_extension.rb @@ -8,4 +8,8 @@ module ApplicationExtension validates :website, url: true, length: { maximum: 2_000 }, if: :website? validates :redirect_uri, length: { maximum: 2_000 } end + + def most_recently_used_access_token + @most_recently_used_access_token ||= access_tokens.where.not(last_used_at: nil).order(last_used_at: :desc).first + end end diff --git a/app/lib/scope_parser.rb b/app/lib/scope_parser.rb new file mode 100644 index 000000000..d268688c8 --- /dev/null +++ b/app/lib/scope_parser.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ScopeParser < Parslet::Parser + rule(:term) { match('[a-z]').repeat(1).as(:term) } + rule(:colon) { str(':') } + rule(:access) { (str('write') | str('read')).as(:access) } + rule(:namespace) { str('admin').as(:namespace) } + rule(:scope) { ((namespace >> colon).maybe >> ((access >> colon >> term) | access | term)).as(:scope) } + root(:scope) +end diff --git a/app/lib/scope_transformer.rb b/app/lib/scope_transformer.rb new file mode 100644 index 000000000..fdfc6cf13 --- /dev/null +++ b/app/lib/scope_transformer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ScopeTransformer < Parslet::Transform + class Scope + DEFAULT_TERM = 'all' + DEFAULT_ACCESS = %w(read write).freeze + + attr_reader :namespace, :term + + def initialize(scope) + @namespace = scope[:namespace]&.to_s + @access = scope[:access] ? [scope[:access].to_s] : DEFAULT_ACCESS.dup + @term = scope[:term]&.to_s || DEFAULT_TERM + end + + def key + @key ||= [@namespace, @term].compact.join('/') + end + + def access + @access.join('/') + end + + def merge(other_scope) + clone.merge!(other_scope) + end + + def merge!(other_scope) + raise ArgumentError unless other_scope.namespace == namespace && other_scope.term == term + + @access.concat(other_scope.instance_variable_get('@access')) + @access.uniq! + @access.sort! + + self + end + end + + rule(scope: subtree(:scope)) { Scope.new(scope) } +end diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml index a2cd1193f..c0ea211ff 100644 --- a/app/views/layouts/modal.html.haml +++ b/app/views/layouts/modal.html.haml @@ -12,8 +12,9 @@ = fa_icon 'sign-out' .container-alt= yield + .modal-layout__mastodon %div - %img{alt:'', draggable:'false', src:"#{mascot_url}"} + %img{alt: '', draggable: 'false', src: mascot_url } = render template: 'layouts/application' diff --git a/app/views/oauth/authorizations/new.html.haml b/app/views/oauth/authorizations/new.html.haml index 05ff9582e..50f671b26 100644 --- a/app/views/oauth/authorizations/new.html.haml +++ b/app/views/oauth/authorizations/new.html.haml @@ -1,26 +1,38 @@ - content_for :page_title do = t('doorkeeper.authorizations.new.title') -.form-container +.form-container.simple_form .oauth-prompt - %h2= t('doorkeeper.authorizations.new.prompt', client_name: @pre_auth.client.name) + %h3= t('doorkeeper.authorizations.new.title') - %p - = t('doorkeeper.authorizations.new.able_to') - != @pre_auth.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.map { |s| "#{s}" }.to_sentence + %p= t('doorkeeper.authorizations.new.prompt_html', client_name: content_tag(:strong, @pre_auth.client.name)) - = form_tag oauth_authorization_path, method: :post, class: 'simple_form' do - = hidden_field_tag :client_id, @pre_auth.client.uid - = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri - = hidden_field_tag :state, @pre_auth.state - = hidden_field_tag :response_type, @pre_auth.response_type - = hidden_field_tag :scope, @pre_auth.scope - = button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit + %h3= t('doorkeeper.authorizations.new.review_permissions') - = form_tag oauth_authorization_path, method: :delete, class: 'simple_form' do - = hidden_field_tag :client_id, @pre_auth.client.uid - = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri - = hidden_field_tag :state, @pre_auth.state - = hidden_field_tag :response_type, @pre_auth.response_type - = hidden_field_tag :scope, @pre_auth.scope - = button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative' + %ul.permissions-list + - grouped_scopes(@pre_auth.scopes).each do |scope| + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('check') + .permissions-list__item__text + .permissions-list__item__text__title + = t(scope.key, scope: [:doorkeeper, :grouped_scopes, :title]) + .permissions-list__item__text__type + = t(scope.access, scope: [:doorkeeper, :grouped_scopes, :access]) + + .actions + = form_tag oauth_authorization_path, method: :post do + = hidden_field_tag :client_id, @pre_auth.client.uid + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri + = hidden_field_tag :state, @pre_auth.state + = hidden_field_tag :response_type, @pre_auth.response_type + = hidden_field_tag :scope, @pre_auth.scope + = button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit + + = form_tag oauth_authorization_path, method: :delete do + = hidden_field_tag :client_id, @pre_auth.client.uid + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri + = hidden_field_tag :state, @pre_auth.state + = hidden_field_tag :response_type, @pre_auth.response_type + = hidden_field_tag :scope, @pre_auth.scope + = button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative' diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml index fbb733db4..fead56f4a 100644 --- a/app/views/oauth/authorized_applications/index.html.haml +++ b/app/views/oauth/authorized_applications/index.html.haml @@ -1,24 +1,44 @@ - content_for :page_title do = t('doorkeeper.authorized_applications.index.title') -.table-wrapper - %table.table - %thead - %tr - %th= t('doorkeeper.authorized_applications.index.application') - %th= t('doorkeeper.authorized_applications.index.scopes') - %th= t('doorkeeper.authorized_applications.index.created_at') - %th - %tbody - - @applications.each do |application| - %tr - %td - - if application.website.blank? - = application.name - - else - = link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer' - %th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join(', ') - %td= l application.created_at - %td - - unless application.superapp? || current_account.suspended? - = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') } +%p= t('doorkeeper.authorized_applications.index.description_html') + +%hr.spacer/ + +.announcements-list + - @applications.each do |application| + .announcements-list__item + - if application.website.present? + = link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer', class: 'announcements-list__item__title' + - else + %strong.announcements-list__item__title + = application.name + - if application.superapp? + %span.account-role.moderator= t('doorkeeper.authorized_applications.index.superapp') + + .announcements-list__item__action-bar + .announcements-list__item__meta + - if application.most_recently_used_access_token + = t('doorkeeper.authorized_applications.index.last_used_at', date: l(application.most_recently_used_access_token.last_used_at.to_date)) + - else + = t('doorkeeper.authorized_applications.index.never_used') + + • + + = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date)) + + - unless application.superapp? || current_account.suspended? + %div + = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') } + + .announcements-list__item__permissions + %ul.permissions-list + - grouped_scopes(application.scopes).each do |scope| + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('check') + .permissions-list__item__text + .permissions-list__item__text__title + = t(scope.key, scope: [:doorkeeper, :grouped_scopes, :title]) + .permissions-list__item__text__type + = t(scope.access, scope: [:doorkeeper, :grouped_scopes, :access]) diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb index adc99c605..7afad2f58 100644 --- a/app/workers/scheduler/ip_cleanup_scheduler.rb +++ b/app/workers/scheduler/ip_cleanup_scheduler.rb @@ -18,6 +18,7 @@ class Scheduler::IpCleanupScheduler 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(sign_up_ip: nil) LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all + Doorkeeper::AccessToken.where('last_used_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_used_ip: nil) end def clean_expired_ip_blocks! diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 8aa099284..5567724ae 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -60,8 +60,8 @@ en: error: title: An error has occurred new: - able_to: It will be able to - prompt: Application %{client_name} requests access to your account + prompt_html: "%{client_name} would like permission to access your account. It is a third-party application. If you do not trust it, then you should not authorize it." + review_permissions: Review permissions title: Authorization required show: title: Copy this authorization code and paste it to the application. @@ -71,10 +71,12 @@ en: confirmations: revoke: Are you sure? index: - application: Application - created_at: Authorized - date_format: "%Y-%m-%d %H:%M:%S" - scopes: Scopes + authorized_at: Authorized on %{date} + description_html: These are applications that can access your account using the API. If there are applications you do not recognize here, or an application is misbehaving, you can revoke its access. + last_used_at: Last used on %{date} + never_used: Never used + scopes: Permissions + superapp: Internal title: Your authorized applications errors: messages: @@ -110,6 +112,33 @@ en: authorized_applications: destroy: notice: Application revoked. + grouped_scopes: + access: + read: Read-only access + read/write: Read and write access + write: Write-only access + title: + accounts: Accounts + admin/accounts: Administration of accounts + admin/all: All administrative functions + admin/reports: Administration of reports + all: Everything + blocks: Blocks + bookmarks: Bookmarks + conversations: Conversations + crypto: End-to-end encryption + favourites: Favourites + filters: Filters + follow: Relationships + follows: Follows + lists: Lists + media: Media attachments + mutes: Mutes + notifications: Notifications + push: Push notifications + reports: Reports + search: Search + statuses: Posts layouts: admin: nav: @@ -124,6 +153,7 @@ en: admin:write: modify all data on the server admin:write:accounts: perform moderation actions on accounts admin:write:reports: perform moderation actions on reports + crypto: use end-to-end encryption follow: modify account relationships push: receive your push notifications read: read all your account's data @@ -143,6 +173,7 @@ en: write:accounts: modify your profile write:blocks: block accounts and domains write:bookmarks: bookmark posts + write:conversations: mute and delete conversations write:favourites: favourite posts write:filters: create filters write:follows: follow people diff --git a/db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb b/db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb new file mode 100644 index 000000000..6b46e60a8 --- /dev/null +++ b/db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb @@ -0,0 +1,6 @@ +class AddLastUsedAtToOauthAccessTokens < ActiveRecord::Migration[6.1] + def change + add_column :oauth_access_tokens, :last_used_at, :datetime + add_column :oauth_access_tokens, :last_used_ip, :inet + end +end diff --git a/db/schema.rb b/db/schema.rb index e54de5b37..756e5e9ab 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: 2022_02_24_010024) do +ActiveRecord::Schema.define(version: 2022_02_27_041951) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -630,6 +630,8 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do t.string "scopes" t.bigint "application_id" t.bigint "resource_owner_id" + t.datetime "last_used_at" + t.inet "last_used_ip" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true diff --git a/spec/lib/scope_transformer_spec.rb b/spec/lib/scope_transformer_spec.rb new file mode 100644 index 000000000..e5a992144 --- /dev/null +++ b/spec/lib/scope_transformer_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ScopeTransformer do + describe '#apply' do + subject { described_class.new.apply(ScopeParser.new.parse(input)) } + + shared_examples 'a scope' do |namespace, term, access| + it 'parses the term' do + expect(subject.term).to eq term + end + + it 'parses the namespace' do + expect(subject.namespace).to eq namespace + end + + it 'parses the access' do + expect(subject.access).to eq access + end + end + + context 'for scope "read"' do + let(:input) { 'read' } + + it_behaves_like 'a scope', nil, 'all', 'read' + end + + context 'for scope "write"' do + let(:input) { 'write' } + + it_behaves_like 'a scope', nil, 'all', 'write' + end + + context 'for scope "follow"' do + let(:input) { 'follow' } + + it_behaves_like 'a scope', nil, 'follow', 'read/write' + end + + context 'for scope "crypto"' do + let(:input) { 'crypto' } + + it_behaves_like 'a scope', nil, 'crypto', 'read/write' + end + + context 'for scope "push"' do + let(:input) { 'push' } + + it_behaves_like 'a scope', nil, 'push', 'read/write' + end + + context 'for scope "admin:read"' do + let(:input) { 'admin:read' } + + it_behaves_like 'a scope', 'admin', 'all', 'read' + end + + context 'for scope "admin:write"' do + let(:input) { 'admin:write' } + + it_behaves_like 'a scope', 'admin', 'all', 'write' + end + + context 'for scope "admin:read:accounts"' do + let(:input) { 'admin:read:accounts' } + + it_behaves_like 'a scope', 'admin', 'accounts', 'read' + end + + context 'for scope "admin:write:accounts"' do + let(:input) { 'admin:write:accounts' } + + it_behaves_like 'a scope', 'admin', 'accounts', 'write' + end + + context 'for scope "read:accounts"' do + let(:input) { 'read:accounts' } + + it_behaves_like 'a scope', nil, 'accounts', 'read' + end + + context 'for scope "write:accounts"' do + let(:input) { 'write:accounts' } + + it_behaves_like 'a scope', nil, 'accounts', 'write' + end + end +end -- cgit From 2ea754b8610b50cc93aeb1921ecdf7415efaf17e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 4 Mar 2022 01:06:33 +0100 Subject: Fix duplicate notifications being possible after poll expiration (#17697) --- app/controllers/api/v1/follow_requests_controller.rb | 2 +- app/lib/activitypub/activity/announce.rb | 2 +- app/lib/activitypub/activity/follow.rb | 4 ++-- app/lib/activitypub/activity/like.rb | 2 +- app/services/bootstrap_timeline_service.rb | 2 +- app/services/favourite_service.rb | 2 +- app/workers/feed_insert_worker.rb | 2 +- app/workers/poll_expiration_notify_worker.rb | 8 +++++--- 8 files changed, 13 insertions(+), 11 deletions(-) (limited to 'app/workers') diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index 8276245a3..54ff0e11d 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -13,7 +13,7 @@ class Api::V1::FollowRequestsController < Api::BaseController def authorize AuthorizeFollowService.new.call(account, current_account) - NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account)) + LocalNotificationWorker.perform_async(current_account.id, Follow.find_by(account: account, target_account: current_account).id, 'Follow', 'follow') render json: account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 7cd5a41e8..0674b1083 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -35,7 +35,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def distribute # Notify the author of the original status if that status is local - NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status) + LocalNotificationWorker.perform_async(@status.reblog.account_id, @status.id, 'Status', 'reblog') if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status) # Distribute into home and list feeds ::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window? diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 4efb84b8c..97e41ab78 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -31,10 +31,10 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id']) if target_account.locked? || @account.silenced? - NotifyService.new.call(target_account, :follow_request, follow_request) + LocalNotificationWorker.perform_async(target_account.id, follow_request.id, 'FollowRequest', 'follow_request') else AuthorizeFollowService.new.call(@account, target_account) - NotifyService.new.call(target_account, :follow, ::Follow.find_by(account: @account, target_account: target_account)) + LocalNotificationWorker.perform_async(target_account.id, ::Follow.find_by(account: @account, target_account: target_account).id, 'Follow', 'follow') end end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index ebbda15b9..aa1dc3040 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -8,7 +8,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity favourite = original_status.favourites.create!(account: @account) - NotifyService.new.call(original_status.account, :favourite, favourite) + LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite') Trends.statuses.register(original_status) end end diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb index 312c163e4..a02e55a6d 100644 --- a/app/services/bootstrap_timeline_service.rb +++ b/app/services/bootstrap_timeline_service.rb @@ -18,7 +18,7 @@ class BootstrapTimelineService < BaseService def notify_staff! User.staff.includes(:account).find_each do |user| - NotifyService.new.call(user.account, :'admin.sign_up', @source_account) + LocalNotificationWorker.perform_async(user.account_id, @source_account.id, 'Account', 'admin.sign_up') end end end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 0ca0081b4..dc7fe8855 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -31,7 +31,7 @@ class FavouriteService < BaseService status = favourite.status if status.account.local? - NotifyService.new.call(status.account, :favourite, favourite) + LocalNotificationWorker.perform_async(status.account_id, favourite.id, 'Favourite', 'favourite') elsif status.account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 6e3472d57..40bc9cb6e 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -66,7 +66,7 @@ class FeedInsertWorker end def perform_notify - NotifyService.new.call(@follower, :status, @status) + LocalNotificationWorker.perform_async(@follower.id, @status.id, 'Status', 'status') end def update? diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb index 7613ed5f1..0e29a5f60 100644 --- a/app/workers/poll_expiration_notify_worker.rb +++ b/app/workers/poll_expiration_notify_worker.rb @@ -38,12 +38,14 @@ class PollExpirationNotifyWorker def notify_remote_voters_and_owner! ActivityPub::DistributePollUpdateWorker.perform_async(@poll.status.id) - NotifyService.new.call(@poll.account, :poll, @poll) + LocalNotificationWorker.perform_async(@poll.account_id, @poll.id, 'Poll', 'poll') end def notify_local_voters! - @poll.voters.merge(Account.local).find_each do |account| - NotifyService.new.call(account, :poll, @poll) + @poll.voters.merge(Account.local).select(:id).find_in_batches do |accounts| + LocalNotificationWorker.push_bulk(accounts) do |account| + [account.id, @poll.id, 'Poll', 'poll'] + end end end end -- cgit