From 7d96d5828e336c7947472c1e9ab5a3186e0957a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KMY=EF=BC=88=E9=9B=AA=E3=81=82=E3=81=99=E3=81=8B=EF=BC=89?= Date: Mon, 26 Feb 2024 17:45:41 +0900 Subject: [PATCH] =?UTF-8?q?Add:=20#600=20NG=E3=83=AB=E3=83=BC=E3=83=AB=20(?= =?UTF-8?q?#602)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Wip * Wip * Wip: History * Wip: テストコード作成 * Fix test * Wip * Wip * Wip * Fix test * Wip * Wip * Wip * Wip * なんとか完成、これから動作確認 * spell miss * Change ng rule timings * Fix test * Wip * Fix test * Wip * Fix form * 表示まわりの改善 --- .haml-lint_todo.yml | 3 + .../admin/ng_rule_histories_controller.rb | 24 ++ app/controllers/admin/ng_rules_controller.rb | 115 +++++++ app/helpers/ng_rule_helper.rb | 28 ++ app/lib/activitypub/activity/announce.rb | 3 + app/lib/activitypub/activity/create.rb | 29 +- app/lib/activitypub/activity/follow.rb | 2 + app/lib/activitypub/activity/like.rb | 4 + app/lib/vacuum/ng_histories_vacuum.rb | 17 ++ app/models/admin/ng_rule.rb | 205 +++++++++++++ app/models/ng_rule.rb | 114 +++++++ app/models/ng_rule_history.rb | 35 +++ app/models/status.rb | 8 + .../process_status_update_service.rb | 34 ++- app/services/emoji_react_service.rb | 8 +- app/services/favourite_service.rb | 3 + app/services/follow_service.rb | 3 + app/services/post_status_service.rb | 44 ++- app/services/process_references_service.rb | 11 +- app/services/reblog_service.rb | 3 + app/services/update_status_service.rb | 43 ++- app/services/vote_service.rb | 3 + .../ng_rule_histories/_history.html.haml | 42 +++ .../admin/ng_rule_histories/show.html.haml | 25 ++ app/views/admin/ng_rules/_ng_rule.html.haml | 25 ++ .../admin/ng_rules/_ng_rule_fields.html.haml | 123 ++++++++ app/views/admin/ng_rules/edit.html.haml | 9 + app/views/admin/ng_rules/index.html.haml | 14 + app/views/admin/ng_rules/new.html.haml | 8 + app/views/admin/ng_words/show.html.haml | 26 +- .../admin/sensitive_words/show.html.haml | 8 +- app/workers/scheduler/vacuum_scheduler.rb | 5 + config/locales/en.yml | 113 ++++++- config/locales/ja.yml | 119 +++++++- config/navigation.rb | 1 + config/routes/admin.rb | 6 + db/migrate/20240218233621_create_ng_rules.rb | 64 ++++ db/schema.rb | 63 +++- lib/tasks/dangerous.rake | 3 + spec/fabricators/ng_rule_fabricator.rb | 7 + .../fabricators/ng_rule_history_fabricator.rb | 8 + .../lib/activitypub/activity/announce_spec.rb | 32 ++ spec/lib/activitypub/activity/create_spec.rb | 63 ++++ spec/lib/activitypub/activity/follow_spec.rb | 27 ++ spec/lib/activitypub/activity/like_spec.rb | 50 ++++ spec/lib/vacuum/ng_histories_vacuum_spec.rb | 24 ++ spec/models/admin/ng_rule_spec.rb | 282 ++++++++++++++++++ spec/models/ng_rule_spec.rb | 29 ++ .../process_status_update_service_spec.rb | 26 ++ spec/services/delete_account_service_spec.rb | 3 + spec/services/emoji_react_service_spec.rb | 27 ++ spec/services/favourite_service_spec.rb | 27 ++ spec/services/follow_service_spec.rb | 26 ++ spec/services/post_status_service_spec.rb | 21 ++ spec/services/reblog_service_spec.rb | 29 ++ spec/services/update_status_service_spec.rb | 28 ++ 56 files changed, 2061 insertions(+), 41 deletions(-) create mode 100644 app/controllers/admin/ng_rule_histories_controller.rb create mode 100644 app/controllers/admin/ng_rules_controller.rb create mode 100644 app/helpers/ng_rule_helper.rb create mode 100644 app/lib/vacuum/ng_histories_vacuum.rb create mode 100644 app/models/admin/ng_rule.rb create mode 100644 app/models/ng_rule.rb create mode 100644 app/models/ng_rule_history.rb create mode 100644 app/views/admin/ng_rule_histories/_history.html.haml create mode 100644 app/views/admin/ng_rule_histories/show.html.haml create mode 100644 app/views/admin/ng_rules/_ng_rule.html.haml create mode 100644 app/views/admin/ng_rules/_ng_rule_fields.html.haml create mode 100644 app/views/admin/ng_rules/edit.html.haml create mode 100644 app/views/admin/ng_rules/index.html.haml create mode 100644 app/views/admin/ng_rules/new.html.haml create mode 100644 db/migrate/20240218233621_create_ng_rules.rb create mode 100644 spec/fabricators/ng_rule_fabricator.rb create mode 100644 spec/fabricators/ng_rule_history_fabricator.rb create mode 100644 spec/lib/vacuum/ng_histories_vacuum_spec.rb create mode 100644 spec/models/admin/ng_rule_spec.rb create mode 100644 spec/models/ng_rule_spec.rb diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index b8c6b17006727c..f66a31447026da 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -10,6 +10,7 @@ linters: # Offense count: 1 LineLength: exclude: + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' - 'app/views/admin/roles/_form.html.haml' # Offense count: 9 @@ -20,9 +21,11 @@ linters: ViewLength: exclude: - 'app/views/admin/instances/show.html.haml' + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' - 'app/views/settings/preferences/appearance/show.html.haml' - 'app/views/settings/preferences/other/show.html.haml' InstanceVariables: exclude: - 'app/views/application/_sidebar.html.haml' + - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml' diff --git a/app/controllers/admin/ng_rule_histories_controller.rb b/app/controllers/admin/ng_rule_histories_controller.rb new file mode 100644 index 00000000000000..9dccefaf49a703 --- /dev/null +++ b/app/controllers/admin/ng_rule_histories_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + class NgRuleHistoriesController < BaseController + before_action :set_ng_rule + before_action :set_histories + + PER_PAGE = 20 + + def show + authorize :ng_words, :show? + end + + private + + def set_ng_rule + @ng_rule = ::NgRule.find(params[:id]) + end + + def set_histories + @histories = NgRuleHistory.where(ng_rule_id: params[:id]).order(id: :desc).page(params[:page]).per(PER_PAGE) + end + end +end diff --git a/app/controllers/admin/ng_rules_controller.rb b/app/controllers/admin/ng_rules_controller.rb new file mode 100644 index 00000000000000..f37424cceddc91 --- /dev/null +++ b/app/controllers/admin/ng_rules_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Admin + class NgRulesController < BaseController + before_action :set_ng_rule, only: [:edit, :update, :destroy, :duplicate] + + def index + authorize :ng_words, :show? + + @ng_rules = ::NgRule.order(id: :asc) + end + + def new + authorize :ng_words, :show? + + @ng_rule = ::NgRule.build + end + + def edit + authorize :ng_words, :show? + end + + def create + authorize :ng_words, :create? + + begin + test_words! + rescue + flash[:alert] = I18n.t('admin.ng_rules.test_error') + redirect_to new_admin_ng_rule_path + return + end + + @ng_rule = ::NgRule.build(resource_params) + + if @ng_rule.save + redirect_to admin_ng_rules_path + else + render :new + end + end + + def update + authorize :ng_words, :create? + + begin + test_words! + rescue + flash[:alert] = I18n.t('admin.ng_rules.test_error') + redirect_to edit_admin_ng_rule_path(id: @ng_rule.id) + return + end + + if @ng_rule.update(resource_params) + redirect_to admin_ng_rules_path + else + render :edit + end + end + + def duplicate + authorize :ng_words, :create? + + @ng_rule = @ng_rule.copy! + + flash[:alert] = I18n.t('admin.ng_rules.copy_error') unless @ng_rule.save + + redirect_to admin_ng_rules_path + end + + def destroy + authorize :ng_words, :create? + + @ng_rule.destroy + redirect_to admin_ng_rules_path + end + + private + + def set_ng_rule + @ng_rule = ::NgRule.find(params[:id]) + end + + def resource_params + params.require(:ng_rule).permit(:title, :expires_in, :available, :account_domain, :account_username, :account_display_name, + :account_note, :account_field_name, :account_field_value, :account_avatar_state, + :account_header_state, :account_include_local, :status_spoiler_text, :status_text, :status_tag, + :status_sensitive_state, :status_cw_state, :status_media_state, :status_poll_state, + :status_mention_state, :status_reference_state, + :status_quote_state, :status_reply_state, :status_media_threshold, :status_poll_threshold, + :status_mention_threshold, :status_allow_follower_mention, + :reaction_allow_follower, :emoji_reaction_name, :emoji_reaction_origin_domain, + :status_reference_threshold, :account_allow_followed_by_local, :record_history_also_local, + status_visibility: [], status_searchability: [], reaction_type: []) + end + + def test_words! + arr = [ + resource_params[:account_domain], + resource_params[:account_username], + resource_params[:account_display_name], + resource_params[:account_note], + resource_params[:account_field_name], + resource_params[:account_field_value], + resource_params[:status_spoiler_text], + resource_params[:status_text], + resource_params[:status_tag], + resource_params[:emoji_reaction_name], + resource_params[:emoji_reaction_origin_domain], + ].compact_blank.join("\n") + + Admin::NgRule.extract_test!(arr) if arr.present? + end + end +end diff --git a/app/helpers/ng_rule_helper.rb b/app/helpers/ng_rule_helper.rb new file mode 100644 index 00000000000000..104442b1171981 --- /dev/null +++ b/app/helpers/ng_rule_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module NgRuleHelper + def check_invalid_status_for_ng_rule!(account, **options) + (check_for_ng_rule!(account, **options) { |rule| !rule.check_status_or_record! }).none? + end + + def check_invalid_reaction_for_ng_rule!(account, **options) + (check_for_ng_rule!(account, **options) { |rule| !rule.check_reaction_or_record! }).none? + end + + private + + def check_for_ng_rule!(account, **options, &block) + NgRule.cached_rules + .map { |raw_rule| Admin::NgRule.new(raw_rule, account, **options) } + .filter(&block) + end + + def do_account_action_for_rule!(account, action) + case action + when :silence + account.silence! + when :suspend + account.suspend! + end + end +end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 9dcafff3abc71a..ceeccd2b3f5392 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::Activity::Announce < ActivityPub::Activity + include NgRuleHelper + def perform return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity? @@ -9,6 +11,7 @@ def perform return reject_payload! if original_status.nil? || !announceable?(original_status) return if requested_through_relay? + return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'reblog', recipient: original_status.account, target_status: original_status @status = Status.find_by(account: @account, reblog: original_status) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 21646524ea8d4c..aa112975f4f59f 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -2,6 +2,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity include FormattingHelper + include NgRuleHelper def perform @account.schedule_refresh_if_stale! @@ -144,7 +145,9 @@ def process_status_params end def valid_status? - valid = !Admin::NgWord.reject?("#{@params[:spoiler_text]}\n#{@params[:text]}", uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?) + valid = true + valid = false if valid && !valid_status_for_ng_rule? + valid = !Admin::NgWord.reject?("#{@params[:spoiler_text]}\n#{@params[:text]}", uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?) if valid valid = !Admin::NgWord.hashtag_reject?(@tags.size, uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?, text: "#{@params[:spoiler_text]}\n#{@params[:text]}") if valid valid = !Admin::NgWord.mention_reject?(@raw_mention_uris.size, uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?, text: "#{@params[:spoiler_text]}\n#{@params[:text]}") if valid valid = !Admin::NgWord.stranger_mention_reject_with_count?(@raw_mention_uris.size, uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?, text: "#{@params[:spoiler_text]}\n#{@params[:text]}") if valid && (mention_to_local_stranger? || reference_to_local_stranger?) @@ -154,6 +157,26 @@ def valid_status? valid end + def valid_status_for_ng_rule? + check_invalid_status_for_ng_rule! @account, + reaction_type: 'create', + uri: @params[:uri], + url: @params[:url], + spoiler_text: @params[:spoiler_text], + text: @params[:text], + tag_names: @tags.map(&:name), + visibility: @params[:visibility].to_s, + searchability: @params[:searchability]&.to_s, + sensitive: @params[:sensitive], + media_count: @params[:media_attachment_ids]&.size, + poll_count: @params[:poll]&.options&.size || 0, + quote: quote, + reply: in_reply_to_uri.present?, + mention_count: mentioned_accounts.count, + reference_count: reference_uris.size, + mention_to_following: !(mention_to_local_stranger? || reference_to_local_stranger?) + end + def accounts_in_audience return @accounts_in_audience if @accounts_in_audience @@ -353,6 +376,8 @@ def process_poll def poll_vote? return false if replied_to_status.nil? || replied_to_status.preloadable_poll.nil? || !replied_to_status.local? || !replied_to_status.preloadable_poll.options.include?(@object['name']) + return true unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'vote', recipient: replied_to_status.account, target_status: replied_to_status + poll_vote! unless replied_to_status.preloadable_poll.expired? true @@ -552,7 +577,7 @@ def reference_uris return @reference_uris if defined?(@reference_uris) @reference_uris = @object['references'].nil? ? [] : (ActivityPub::FetchReferencesService.new.call(@account, @object['references']) || []).uniq - @reference_uris += ProcessReferencesService.extract_uris(@object['content'] || '') + @reference_uris += ProcessReferencesService.extract_uris(@object['content'] || '', remote: true) end def local_referred_accounts diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index d327e254759c33..677b9e069ef3d9 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -3,6 +3,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity include Payloadable include FollowHelper + include NgRuleHelper def perform return request_follow_for_friend if friend_follow? @@ -10,6 +11,7 @@ def perform target_account = account_from_uri(object_uri) return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'follow', recipient: target_account # Update id of already-existing follow requests existing_follow_request = ::FollowRequest.find_by(account: @account, target_account: target_account) || PendingFollowRequest.find_by(account: @account, target_account: target_account) diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index dd02e9ffcf7812..4f149857c3dcd5 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -4,6 +4,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity include Redisable include Lockable include JsonLdHelper + include NgRuleHelper def perform @original_status = status_from_uri(object_uri) @@ -25,6 +26,7 @@ def reject_favourite? def process_favourite return if @account.favourited?(@original_status) + return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'favourite', recipient: @original_status.account, target_status: @original_status favourite = @original_status.favourites.create!(account: @account, uri: @json['id']) @@ -43,6 +45,8 @@ def process_emoji_reaction return if emoji.nil? end + return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'emoji_reaction', emoji_reaction_name: emoji&.shortcode || shortcode, emoji_reaction_origin_domain: emoji&.domain, recipient: @original_status.account, target_status: @original_status + reaction = nil with_redis_lock("emoji_reaction:#{@original_status.id}") do diff --git a/app/lib/vacuum/ng_histories_vacuum.rb b/app/lib/vacuum/ng_histories_vacuum.rb new file mode 100644 index 00000000000000..204e6de23fa0e6 --- /dev/null +++ b/app/lib/vacuum/ng_histories_vacuum.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Vacuum::NgHistoriesVacuum + include Redisable + + HISTORY_LIFE_DURATION = 7.days.freeze + + def perform + vacuum_histories! + end + + private + + def vacuum_histories! + NgRuleHistory.where('created_at < ?', HISTORY_LIFE_DURATION.ago).in_batches.destroy_all + end +end diff --git a/app/models/admin/ng_rule.rb b/app/models/admin/ng_rule.rb new file mode 100644 index 00000000000000..8746457b808725 --- /dev/null +++ b/app/models/admin/ng_rule.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +class Admin::NgRule + def initialize(ng_rule, account, **options) + @ng_rule = ng_rule + @account = account + @options = options + @uri = nil + end + + def account_match? + return false if @account.local? && !@ng_rule.account_include_local + return false if !@account.local? && @ng_rule.account_allow_followed_by_local && followed_by_local_accounts? + + if @account.local? + return false unless @ng_rule.account_include_local + else + return false unless text_match?(:account_domain, @account.domain, @ng_rule.account_domain) + end + + text_match?(:account_username, @account.username, @ng_rule.account_username) && + text_match?(:account_display_name, @account.display_name, @ng_rule.account_display_name) && + text_match?(:account_note, @account.note, @ng_rule.account_note) && + text_match?(:account_field_name, @account.fields&.map(&:name)&.join("\n"), @ng_rule.account_field_name) && + text_match?(:account_field_value, @account.fields&.map(&:value)&.join("\n"), @ng_rule.account_field_value) && + media_state_match?(:account_avatar_state, @account.avatar, @ng_rule.account_avatar_state) && + media_state_match?(:account_header_state, @account.header, @ng_rule.account_header_state) + end + + def status_match? # rubocop:disable Metrics/CyclomaticComplexity + return false if @ng_rule.status_allow_follower_mention && @options[:mention_to_following] + + has_media = @options[:media_count].is_a?(Integer) && @options[:media_count].positive? + has_poll = @options[:poll_count].is_a?(Integer) && @options[:poll_count].positive? + has_mention = @options[:mention_count].is_a?(Integer) && @options[:mention_count].positive? + has_reference = @options[:reference_count].is_a?(Integer) && @options[:reference_count].positive? + + @options = @options.merge({ searchability: 'unset' }) if @options[:searchability].nil? + + text_match?(:status_spoiler_text, @options[:spoiler_text], @ng_rule.status_spoiler_text) && + text_match?(:status_text, @options[:text], @ng_rule.status_text) && + text_match?(:status_tag, @options[:tag_names]&.join("\n"), @ng_rule.status_tag) && + enum_match?(:status_visibility, @options[:visibility], @ng_rule.status_visibility) && + enum_match?(:status_searchability, @options[:searchability], @ng_rule.status_searchability) && + state_match?(:status_sensitive_state, @options[:sensitive], @ng_rule.status_sensitive_state) && + state_match?(:status_cw_state, @options[:spoiler_text].present?, @ng_rule.status_cw_state) && + state_match?(:status_media_state, has_media, @ng_rule.status_media_state) && + state_match?(:status_poll_state, has_poll, @ng_rule.status_poll_state) && + state_match?(:status_quote_state, @options[:quote], @ng_rule.status_quote_state) && + state_match?(:status_reply_state, @options[:reply], @ng_rule.status_reply_state) && + state_match?(:status_mention_state, has_mention, @ng_rule.status_mention_state) && + state_match?(:status_reference_state, has_reference, @ng_rule.status_reference_state) && + value_over_threshold?(:status_tag_threshold, (@options[:tag_names] || []).size, @ng_rule.status_tag_threshold) && + value_over_threshold?(:status_media_threshold, @options[:media_count], @ng_rule.status_media_threshold) && + value_over_threshold?(:status_poll_threshold, @options[:poll_count], @ng_rule.status_poll_threshold) && + value_over_threshold?(:status_mention_threshold, @options[:mention_count], @ng_rule.status_mention_threshold) && + value_over_threshold?(:status_reference_threshold, @options[:reference_count], @ng_rule.status_reference_threshold) + end + + def reaction_match? + recipient = @options[:recipient] + return false if @ng_rule.reaction_allow_follower && (recipient.id == @account.id || (!recipient.local? && !@account.local?) || recipient.following?(@account)) + + if @options[:reaction_type] == 'emoji_reaction' + enum_match?(:reaction_type, @options[:reaction_type], @ng_rule.reaction_type) && + text_match?(:emoji_reaction_name, @options[:emoji_reaction_name], @ng_rule.emoji_reaction_name) && + text_match?(:emoji_reaction_origin_domain, @options[:emoji_reaction_origin_domain], @ng_rule.emoji_reaction_origin_domain) + else + enum_match?(:reaction_type, @options[:reaction_type], @ng_rule.reaction_type) + end + end + + def check_account_or_record! + return true unless account_match? + + record!('account', @account.uri, 'account_create') if !@account.local? || @ng_rule.record_history_also_local + + false + end + + def check_status_or_record! + return true unless account_match? && status_match? + + text = [@options[:spoiler_text], @options[:text]].compact_blank.join("\n\n") + data = { + media_count: @options[:media_count], + poll_count: @options[:poll_count], + url: @options[:url], + } + record!('status', @options[:uri], "status_#{@options[:reaction_type]}", text: text, data: data) if !@account.local? || @ng_rule.record_history_also_local + + false + end + + def check_reaction_or_record! + return true unless account_match? && reaction_match? + + text = @options[:target_status].present? ? [@options[:target_status].spoiler_text, @options[:target_status].text].compact_blank.join("\n\n") : nil + data = { + url: @options[:target_status].present? ? @options[:target_status].url : nil, + } + record!('reaction', @options[:uri], "reaction_#{@options[:reaction_type]}", text: text, data: data) if !@account.local? || @ng_rule.record_history_also_local + + false + end + + def loggable_visibility? + visibility = @options[:target_status]&.visibility || @options[:visibility] + return true unless visibility + + %i(public public_unlisted login unlisted).include?(visibility.to_sym) + end + + def self.extract_test!(custom_ng_words) + detect_keyword?('test', custom_ng_words) + end + + private + + def followed_by_local_accounts? + Follow.exists?(account: Account.local, target_account: @account) + end + + def record!(reason, uri, reason_action, **options) + opts = options.merge({ + ng_rule: @ng_rule, + account: @account, + local: @account.local?, + reason: reason, + reason_action: reason_action, + uri: uri, + }) + + unless loggable_visibility? + opts = opts.merge({ + text: nil, + uri: nil, + hidden: true, + }) + end + + NgRuleHistory.create!(**opts) + end + + def text_match?(_reason, text, arr) + return true if arr.blank? || !text.is_a?(String) + + detect_keyword?(text, arr) + end + + def enum_match?(_reason, text, arr) + return true if !text.is_a?(String) || text.blank? + + arr.include?(text) + end + + def state_match?(_reason, exists, expected) + case expected.to_sym + when :needed + exists + when :no_needed + !exists + else + true + end + end + + def media_state_match?(reason, media, expected) + state_match?(reason, media.present?, expected) + end + + def value_over_threshold?(_reason, value, expected) + return true if !expected.is_a?(Integer) || expected.negative? || !value.is_a?(Integer) + + value > expected + end + + def detect_keyword?(text, arr) + Admin::NgRule.detect_keyword?(text, arr) + end + + class << self + def string_to_array(text) + text.delete("\r").split("\n") + end + + def detect_keyword(text, arr) + arr = string_to_array(arr) if arr.is_a?(String) + + arr.detect { |word| include?(text, word) ? word : nil } + end + + def detect_keyword?(text, arr) + detect_keyword(text, arr).present? + end + + def include?(text, word) + if word.start_with?('?') && word.size >= 2 + text =~ /#{word[1..]}/ + else + text.include?(word) + end + end + end +end diff --git a/app/models/ng_rule.rb b/app/models/ng_rule.rb new file mode 100644 index 00000000000000..78d2979c4038d0 --- /dev/null +++ b/app/models/ng_rule.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: ng_rules +# +# id :bigint(8) not null, primary key +# title :string default(""), not null +# available :boolean default(TRUE), not null +# record_history_also_local :boolean default(TRUE), not null +# account_domain :string default(""), not null +# account_username :string default(""), not null +# account_display_name :string default(""), not null +# account_note :string default(""), not null +# account_field_name :string default(""), not null +# account_field_value :string default(""), not null +# account_avatar_state :integer default("optional"), not null +# account_header_state :integer default("optional"), not null +# account_include_local :boolean default(TRUE), not null +# account_allow_followed_by_local :boolean default(FALSE), not null +# status_spoiler_text :string default(""), not null +# status_text :string default(""), not null +# status_tag :string default(""), not null +# status_visibility :string default([]), not null, is an Array +# status_searchability :string default([]), not null, is an Array +# status_media_state :integer default("optional"), not null +# status_sensitive_state :integer default("optional"), not null +# status_cw_state :integer default("optional"), not null +# status_poll_state :integer default("optional"), not null +# status_quote_state :integer default("optional"), not null +# status_reply_state :integer default("optional"), not null +# status_mention_state :integer default(0), not null +# status_reference_state :integer default(0), not null +# status_tag_threshold :integer default(-1), not null +# status_media_threshold :integer default(-1), not null +# status_poll_threshold :integer default(-1), not null +# status_mention_threshold :integer default(-1), not null +# status_allow_follower_mention :boolean default(TRUE), not null +# status_reference_threshold :integer default(-1), not null +# reaction_type :string default([]), not null, is an Array +# reaction_allow_follower :boolean default(TRUE), not null +# emoji_reaction_name :string default(""), not null +# emoji_reaction_origin_domain :string default(""), not null +# expires_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# +class NgRule < ApplicationRecord + include Expireable + include Redisable + + has_many :histories, class_name: 'NgRuleHistory', inverse_of: :ng_rule, dependent: :destroy + + enum :account_avatar_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :account_avatar + enum :account_header_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :account_header + enum :status_sensitive_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_sensitive + enum :status_cw_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_cw + enum :status_quote_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_quote + enum :status_reply_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_reply + enum :status_media_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_media + enum :status_poll_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_poll + enum :status_mention_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_mention + enum :status_reference_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_reference + + scope :enabled, -> { where(available: true) } + + before_validation :clean_up_arrays + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! + + def self.cached_rules + active_rules = Rails.cache.fetch('ng_rules') do + NgRule.enabled.to_a + end + + active_rules.reject { |ng_rule, _| ng_rule.expired? } + end + + def expires_in + return @expires_in if defined?(@expires_in) + return nil if expires_at.nil? + + [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week, 2.weeks, 1.month, 3.months].find { |expires_in| expires_in.from_now >= expires_at } + end + + def copy! + dup + end + + def hit_count + Rails.cache.fetch("ng_rule:hit_count:#{id}", expires_in: 15.minutes) { NgRuleHistory.where(ng_rule_id: id).count } + end + + private + + def clean_up_arrays + self.status_visibility = Array(status_visibility).map(&:strip).filter_map(&:presence) + self.status_searchability = Array(status_searchability).map(&:strip).filter_map(&:presence) + self.reaction_type = Array(reaction_type).map(&:strip).filter_map(&:presence) + end + + def prepare_cache_invalidation! + @should_invalidate_cache = true + end + + def invalidate_cache! + return unless @should_invalidate_cache + + @should_invalidate_cache = false + + Rails.cache.delete('ng_rules') + end +end diff --git a/app/models/ng_rule_history.rb b/app/models/ng_rule_history.rb new file mode 100644 index 00000000000000..07cdbadf08a4d9 --- /dev/null +++ b/app/models/ng_rule_history.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: ng_rule_histories +# +# id :bigint(8) not null, primary key +# ng_rule_id :bigint(8) not null +# account_id :bigint(8) +# text :string +# uri :string +# reason :integer not null +# reason_action :integer not null +# local :boolean default(TRUE), not null +# hidden :boolean default(FALSE), not null +# data :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# +class NgRuleHistory < ApplicationRecord + enum :reason, { account: 0, status: 1, reaction: 2 }, prefix: :reason + enum :reason_action, { + account_create: 0, + status_create: 10, + status_edit: 11, + reaction_favourite: 20, + reaction_emoji_reaction: 21, + reaction_follow: 22, + reaction_reblog: 23, + reaction_vote: 24, + }, prefix: :reason_action + + belongs_to :ng_rule + belongs_to :account +end diff --git a/app/models/status.rb b/app/models/status.rb index 27fc522290d39b..eb57761cb504db 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -504,6 +504,10 @@ def selectable_reblog_visibilities %w(unset) + selectable_visibilities end + def all_visibilities + visibilities.keys + end + def selectable_searchabilities ss = searchabilities.keys - %w(unsupported) ss -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility @@ -514,6 +518,10 @@ def selectable_searchabilities_for_search searchabilities.keys - %w(public_unlisted unsupported) end + def all_searchabilities + searchabilities.keys - %w(unlisted login unsupported) + end + def favourites_map(status_ids, account_id) Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true } end diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 97896287ea03e4..19b1fa9ae4e717 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -4,6 +4,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService include JsonLdHelper include Redisable include Lockable + include NgRuleHelper class AbortError < ::StandardError; end @@ -168,17 +169,38 @@ def valid_status? end def validate_status_mentions! - raise AbortError if (mention_to_stranger? || reference_to_stranger?) && Admin::NgWord.stranger_mention_reject?("#{@status_parser.spoiler_text}\n#{@status_parser.text}", uri: @status.uri, target_type: :status) + raise AbortError unless valid_status_for_ng_rule? + raise AbortError if (mention_to_local_stranger? || reference_to_local_stranger?) && Admin::NgWord.stranger_mention_reject?("#{@status_parser.spoiler_text}\n#{@status_parser.text}", uri: @status.uri, target_type: :status) raise AbortError if Admin::NgWord.mention_reject?(@raw_mentions.size, uri: @status.uri, target_type: :status, text: "#{@status_parser.spoiler_text}\n#{@status_parser.text}") - raise AbortError if (mention_to_stranger? || reference_to_stranger?) && Admin::NgWord.stranger_mention_reject_with_count?(@raw_mentions.size, uri: @status.uri, target_type: :status, text: "#{@status_parser.spoiler_text}\n#{@status_parser.text}") - end - - def mention_to_stranger? + raise AbortError if (mention_to_local_stranger? || reference_to_local_stranger?) && Admin::NgWord.stranger_mention_reject_with_count?(@raw_mentions.size, uri: @status.uri, target_type: :status, text: "#{@status_parser.spoiler_text}\n#{@status_parser.text}") + end + + def valid_status_for_ng_rule? + check_invalid_status_for_ng_rule! @account, + reaction_type: 'edit', + uri: @status.uri, + url: @status_parser.url || @status.url, + spoiler_text: @status.spoiler_text, + text: @status.text, + tag_names: @raw_tags, + visibility: @status.visibility, + searchability: @status.searchability, + sensitive: @status.sensitive, + media_count: @next_media_attachments.size, + poll_count: @status.poll&.options&.size || 0, + quote: quote, + reply: @status.reply?, + mention_count: @status.mentions.count, + reference_count: reference_uris.size, + mention_to_following: !(mention_to_local_stranger? || reference_to_local_stranger?) + end + + def mention_to_local_stranger? @status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @status.account.id && mentioned_account.local? && !mentioned_account.following?(@status.account) } || (@status.thread.present? && @status.thread.account.id != @status.account.id && @status.thread.account.local? && !@status.thread.account.following?(@status.account)) end - def reference_to_stranger? + def reference_to_local_stranger? local_referred_accounts.any? { |account| !account.following?(@account) } end diff --git a/app/services/emoji_react_service.rb b/app/services/emoji_react_service.rb index a71bd94ba10438..f7d4b0ff37d599 100644 --- a/app/services/emoji_react_service.rb +++ b/app/services/emoji_react_service.rb @@ -5,6 +5,7 @@ class EmojiReactService < BaseService include Payloadable include Redisable include Lockable + include NgRuleHelper # React a status with emoji and notify remote user # @param [Account] account @@ -18,9 +19,12 @@ def call(account, status, name) raise Mastodon::ValidationError, I18n.t('reactions.errors.banned') if account.silenced? && !status.account.following?(account) + shortcode, domain = name.split('@') + domain = nil if TagManager.instance.local_domain?(domain) + + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! account, reaction_type: 'emoji_reaction', emoji_reaction_name: shortcode, emoji_reaction_origin_domain: domain, recipient: status.account, target_status: status + with_redis_lock("emoji_reaction:#{status.id}") do - shortcode, domain = name.split('@') - domain = nil if TagManager.instance.local_domain?(domain) custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) return if domain.present? && !EmojiReaction.exists?(status: status, custom_emoji: custom_emoji) diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index ded50187f77485..50ed5831eac4f1 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -3,6 +3,7 @@ class FavouriteService < BaseService include Authorization include Payloadable + include NgRuleHelper # Favourite a status and notify remote user # @param [Account] account @@ -11,6 +12,8 @@ class FavouriteService < BaseService def call(account, status) authorize_with account, status, :favourite? + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! account, reaction_type: 'favourite', recipient: status.account, target_status: status + favourite = Favourite.find_by(account: account, status: status) return favourite unless favourite.nil? diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 52e8171a738978..f269916c4d8eac 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -4,6 +4,7 @@ class FollowService < BaseService include Redisable include Payloadable include DomainControlHelper + include NgRuleHelper # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow @@ -23,6 +24,8 @@ def call(source_account, target_account, options = {}) raise ActiveRecord::RecordNotFound if following_not_possible? raise Mastodon::NotPermittedError if following_not_allowed? + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! @source_account, reaction_type: 'follow', recipient: @target_account + if @source_account.following?(@target_account) return change_follow_options! elsif @source_account.requested?(@target_account) diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 3c4eb9c12f0c81..fb049946123347 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -4,6 +4,7 @@ class PostStatusService < BaseService include Redisable include LanguagesHelper include DtlHelper + include NgRuleHelper MIN_SCHEDULE_OFFSET = 5.minutes.freeze @@ -74,7 +75,7 @@ def preprocess_attributes! @visibility = :limited if %w(mutual circle reply).include?(@options[:visibility]) @visibility = :unlisted if (@visibility == :public || @visibility == :public_unlisted || @visibility == :login) && @account.silenced? @visibility = :public_unlisted if @visibility == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility - @visibility = Setting.enable_public_unlisted_visibility ? :public_unlisted : :unlisted unless Setting.enable_public_visibility + @visibility = Setting.enable_public_unlisted_visibility ? :public_unlisted : :unlisted if !Setting.enable_public_visibility && @visibility == :public @limited_scope = @options[:visibility]&.to_sym if @visibility == :limited && @options[:visibility] != 'limited' @searchability = searchability @searchability = :private if @account.silenced? && %i(public public_unlisted).include?(@searchability&.to_sym) @@ -148,6 +149,7 @@ def process_status! @status = @account.statuses.new(status_attributes) process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? @limited_scope : '', circle: @circle, save_records: false) safeguard_mentions!(@status) + validate_status_ng_rules! validate_status_mentions! @status.limited_scope = :personal if @status.limited_visibility? && !@status.reply_limited? && !process_mentions_service.mentions? @@ -218,9 +220,35 @@ def validate_status_mentions! raise Mastodon::ValidationError, I18n.t('statuses.contains_ng_words') if (mention_to_stranger? || reference_to_stranger?) && Setting.stranger_mention_from_local_ng && Admin::NgWord.stranger_mention_reject?("#{@options[:spoiler_text]}\n#{@options[:text]}") end + def validate_status_ng_rules! + result = check_invalid_status_for_ng_rule! @account, + reaction_type: 'create', + spoiler_text: @options[:spoiler_text] || '', + text: @text, + tag_names: Extractor.extract_hashtags(@text) || [], + visibility: @visibility.to_s, + searchability: @searchability.to_s, + sensitive: @sensitive, + media_count: (@media || []).size, + poll_count: @options.dig(:poll, 'options')&.size || 0, + quote: quote_url, + reply: @in_reply_to.present?, + mention_count: mention_count, + reference_count: reference_urls.size, + mention_to_following: !(mention_to_stranger? || reference_to_stranger?) + + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless result + end + + def mention_count + @text.gsub(Account::MENTION_RE)&.count || 0 + end + def mention_to_stranger? - @status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @account.id && !mentioned_account.following?(@account) } || - (@in_reply_to && @in_reply_to.account.id != @account.id && !@in_reply_to.account.following?(@account)) + return @mention_to_stranger if defined?(@mention_to_stranger) + + @mention_to_stranger = @status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @account.id && !mentioned_account.following?(@account) } || + (@in_reply_to && @in_reply_to.account.id != @account.id && !@in_reply_to.account.following?(@account)) end def reference_to_stranger? @@ -228,12 +256,20 @@ def reference_to_stranger? end def referred_statuses - statuses = ProcessReferencesService.extract_uris(@text).filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) } + statuses = reference_urls.filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) } statuses += Status.where(id: @reference_ids) if @reference_ids.present? statuses end + def quote_url + ProcessReferencesService.extract_quote(@text) + end + + def reference_urls + @reference_urls ||= ProcessReferencesService.extract_uris(@text) || [] + end + def validate_media! if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) @media = [] diff --git a/app/services/process_references_service.rb b/app/services/process_references_service.rb index 4177dcb3832268..807e1571b23ee0 100644 --- a/app/services/process_references_service.rb +++ b/app/services/process_references_service.rb @@ -8,6 +8,7 @@ class ProcessReferencesService < BaseService DOMAIN = ENV['WEB_DOMAIN'] || ENV.fetch('LOCAL_DOMAIN', nil) REFURL_EXP = /(RT|QT|BT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/ + QUOTEURL_EXP = /(QT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/ MAX_REFERENCES = 5 def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_urls: nil, quote_urls: nil) @@ -45,8 +46,14 @@ def self.need_process?(status, reference_parameters, urls, quote_urls) reference_parameters.any? || (urls || []).any? || (quote_urls || []).any? || FormattingHelper.extract_status_plain_text(status).scan(REFURL_EXP).pluck(3).uniq.any? end - def self.extract_uris(text) - text.scan(REFURL_EXP).pluck(3) + def self.extract_uris(text, remote: false) + return text.scan(REFURL_EXP).pluck(3) unless remote + + PlainTextFormatter.new(text, false).to_s.scan(REFURL_EXP).pluck(3) + end + + def self.extract_quote(text) + text.scan(QUOTEURL_EXP).pick(3) end def self.perform_worker_async(status, reference_parameters, urls, quote_urls) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 7e52f82a8301a0..2c04d2becd505d 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -3,6 +3,7 @@ class ReblogService < BaseService include Authorization include Payloadable + include NgRuleHelper # Reblog a status and notify its remote author # @param [Account] account Account to reblog from @@ -16,6 +17,8 @@ def call(account, reblogged_status, options = {}) authorize_with account, reblogged_status, :reblog? + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! account, reaction_type: 'reblog', recipient: reblogged_status.account, target_status: reblogged_status + reblog = account.statuses.find_by(reblog: reblogged_status) return reblog unless reblog.nil? diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 1393e7eefaff74..e21eab80665315 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -3,6 +3,7 @@ class UpdateStatusService < BaseService include Redisable include LanguagesHelper + include NgRuleHelper class NoChangesSubmittedError < StandardError; end @@ -31,6 +32,8 @@ def call(status, account_id, options = {}) validate_status! Status.transaction do + validate_status_ng_rules! + create_previous_edit! unless @options[:no_history] update_media_attachments! if @options.key?(:media_ids) update_poll! if @options.key?(:poll) @@ -91,6 +94,30 @@ def validate_status_mentions! raise Mastodon::ValidationError, I18n.t('statuses.contains_ng_words') if (mention_to_stranger? || reference_to_stranger?) && Setting.stranger_mention_from_local_ng && Admin::NgWord.stranger_mention_reject?("#{@options[:spoiler_text]}\n#{@options[:text]}") end + def validate_status_ng_rules! + result = check_invalid_status_for_ng_rule! @status.account, + reaction_type: 'edit', + spoiler_text: @options.key?(:spoiler_text) ? (@options[:spoiler_text] || '') : @status.spoiler_text, + text: text, + tag_names: Extractor.extract_hashtags(text) || [], + visibility: @status.visibility, + searchability: @status.searchability, + sensitive: @options.key?(:sensitive) ? @options[:sensitive] : @status.sensitive, + media_count: @options[:media_ids].present? ? @options[:media_ids].size : @status.media_attachments.count, + poll_count: @options.dig(:poll, 'options')&.size || 0, + quote: quote_url, + reply: @status.reply?, + mention_count: mention_count, + reference_count: reference_urls.size, + mention_to_following: !(mention_to_stranger? || reference_to_stranger?) + + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless result + end + + def mention_count + text.gsub(Account::MENTION_RE)&.count || 0 + end + def mention_to_stranger? @status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @status.account.id && !mentioned_account.following?(@status.account) } || (@status.thread.present? && @status.thread.account.id != @status.account.id && !@status.thread.account.following?(@status.account)) @@ -101,9 +128,21 @@ def reference_to_stranger? end def referred_statuses - return [] unless @options[:text] + return [] unless text + + reference_urls.filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) } + end + + def quote_url + ProcessReferencesService.extract_quote(text) + end + + def reference_urls + @reference_urls ||= ProcessReferencesService.extract_uris(text) || [] + end - ProcessReferencesService.extract_uris(@options[:text]).filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) } + def text + @options.key?(:text) ? (@options[:text] || '') : @status.text end def validate_media! diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb index 878350388b8bae..d4f5006be3d87c 100644 --- a/app/services/vote_service.rb +++ b/app/services/vote_service.rb @@ -5,6 +5,7 @@ class VoteService < BaseService include Payloadable include Redisable include Lockable + include NgRuleHelper def call(account, poll, choices) return if choices.empty? @@ -16,6 +17,8 @@ def call(account, poll, choices) @choices = choices @votes = [] + raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! @account, reaction_type: 'vote', recipient: @poll.status.account, target_status: @poll.status + already_voted = true with_redis_lock("vote:#{@poll.id}:#{@account.id}") do diff --git a/app/views/admin/ng_rule_histories/_history.html.haml b/app/views/admin/ng_rule_histories/_history.html.haml new file mode 100644 index 00000000000000..1425b1cda22494 --- /dev/null +++ b/app/views/admin/ng_rule_histories/_history.html.haml @@ -0,0 +1,42 @@ +.batch-table__row + %label.batch-table__row__select.batch-checkbox + -# = f.check_box :history_ids, { multiple: true, include_hidden: false }, history.id + .batch-table__row__content + - if history.hidden + .simple_form + %p.hint= t('admin.ng_rule_histories.hidden') + - else + .status__content>< + = html_aware_format(history.text, history.local) + + .detailed-status__meta + = t("admin.ng_rule_histories.reason_actions.#{history.reason_action}") + - if history.data.present? && !history.hidden + - if history.data['media_count'].present? && history.data['media_count'].positive? + · + = t('admin.ng_rule_histories.data.media_count', count: history.data['media_count']) + - if history.data['poll_count'].present? && history.data['poll_count'].positive? + · + = t('admin.ng_rule_histories.data.poll_count', count: history.data['poll_count']) + + %br/ + + - if history.account.present? + - if history.hidden + - if history.account.local? + = t('admin.ng_rule_histories.from_local_user') + - else + = history.account.domain + · + - else + = link_to t('admin.ng_rule_histories.moderate_account'), admin_account_path(history.account.id) + · + + %time.formatted{ datetime: history.created_at.iso8601, title: l(history.created_at) }= l(history.created_at) + + - if history.uri.present? && !history.hidden + · + - if history.data.present? && history.data['url'].present? + = link_to history.uri, history.data['url'] || history.uri, target: '_blank', rel: 'noopener' + - else + = link_to history.uri, target: '_blank', rel: 'noopener' diff --git a/app/views/admin/ng_rule_histories/show.html.haml b/app/views/admin/ng_rule_histories/show.html.haml new file mode 100644 index 00000000000000..9c074b2e05db36 --- /dev/null +++ b/app/views/admin/ng_rule_histories/show.html.haml @@ -0,0 +1,25 @@ +- content_for :page_title do + = t('admin.ng_rule_histories.title', title: @ng_rule.title) + +.filters + .back-link + = link_to edit_admin_ng_rule_path(id: @ng_rule.id) do + = fa_icon 'chevron-left fw' + = t('admin.ng_rule_histories.back_to_ng_rule') + = link_to admin_ng_rules_path do + = fa_icon 'chevron-left fw' + = t('admin.ng_rule_histories.back_to_ng_rules') + +%hr.spacer/ + +.batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__body + - if @histories.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'admin/ng_rule_histories/history', collection: @histories + += paginate @histories diff --git a/app/views/admin/ng_rules/_ng_rule.html.haml b/app/views/admin/ng_rules/_ng_rule.html.haml new file mode 100644 index 00000000000000..4313363535f250 --- /dev/null +++ b/app/views/admin/ng_rules/_ng_rule.html.haml @@ -0,0 +1,25 @@ +.filters-list__item{ class: [(ng_rule.expired? || !ng_rule.available) && 'expired'] } + = link_to edit_admin_ng_rule_path(ng_rule), class: 'filters-list__item__title' do + = ng_rule.title.presence || "(#{t('admin.ng_rules.index.empty_title')})" + + - if ng_rule.expires? + .expiration{ title: t('filters.index.expires_on', date: l(ng_rule.expires_at)) } + - if ng_rule.expired? + = t('invites.expired') + - else + = t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(ng_rule.expires_at)) + - elsif !ng_rule.available + .expiration + = t('admin.ng_rules.index.disabled') + + .filters-list__item__permissions + %ul.permissions-list + + .announcements-list__item__action-bar + .announcements-list__item__meta + = link_to t('admin.ng_rules.index.hit_count', count: ng_rule.hit_count), admin_ng_rule_history_path(ng_rule) + + %div + = table_link_to 'pencil', t('admin.ng_rules.index.edit.title'), edit_admin_ng_rule_path(ng_rule) + = table_link_to 'files-o', t('admin.ng_rules.copy'), duplicate_admin_ng_rule_path(ng_rule), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } + = table_link_to 'times', t('admin.ng_rules.index.delete'), admin_ng_rule_path(ng_rule), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/ng_rules/_ng_rule_fields.html.haml b/app/views/admin/ng_rules/_ng_rule_fields.html.haml new file mode 100644 index 00000000000000..139fd65bd2a176 --- /dev/null +++ b/app/views/admin/ng_rules/_ng_rule_fields.html.haml @@ -0,0 +1,123 @@ + +.fields-group + %p= t('admin.ng_rules.edit.helps.generic') + +.fields-group + %p + = t('admin.ng_rules.edit.helps.textarea_html') + = link_to t('admin.ng_rules.rubular'), 'https://rubular.com/', target: '_blank', rel: 'noopener' + +.fields-group + %p= t('admin.ng_rules.edit.helps.threshold_html') + +- if @ng_rule.id.present? + %p.hint= link_to t('admin.ng_rules.edit.history'), admin_ng_rule_history_path(id: @ng_rule.id) + +%hr.spacer/ + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :title, as: :string, wrapper: :with_label, hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week, 2.weeks, 1.month, 3.months].map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') + +%h4= t('admin.ng_rules.edit.headers.account') +%p.lead= t('admin.ng_rules.edit.summary.account') + +.fields-group + = f.input :account_allow_followed_by_local, as: :boolean, wrapper: :with_label, hint: t('admin.ng_rules.account_allow_followed_by_local_hint'), label: t('admin.ng_rules.account_allow_followed_by_local') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_domain, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_domain'), hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_include_local, as: :boolean, wrapper: :with_label, label: t('admin.ng_rules.account_include_local'), hint: false + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_username, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_username'), hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_display_name, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_display_name'), hint: false + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_field_name, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_field_name'), hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_field_value, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_field_value'), hint: false + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_note, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_note'), hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :account_avatar_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.account_avatar_state') + = f.input :account_header_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.account_header_state') + +%h4= t('admin.ng_rules.edit.headers.status') +%p.lead= t('admin.ng_rules.edit.summary.status') + +.fields-group + = f.input :status_allow_follower_mention, as: :boolean, wrapper: :with_label, hint: t('admin.ng_rules.status_allow_follower_mention_hint'), label: t('admin.ng_rules.status_allow_follower_mention') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_text, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.status_text'), hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_spoiler_text, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.status_spoiler_text'), hint: false + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_tag, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.status_tag'), hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_sensitive_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_sensitive_state') + = f.input :status_cw_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_cw_state') + +.fields-group + = f.input :status_visibility, wrapper: :with_block_label, collection: Status.all_visibilities, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("statuses.visibilities.#{context}") }, include_blank: false, label: t('admin.ng_rules.status_visibility') + +.fields-group + = f.input :status_searchability, wrapper: :with_block_label, collection: Status.all_searchabilities + %w(unset), as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("statuses.searchabilities.#{context}") }, include_blank: false, label: t('admin.ng_rules.status_searchability') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_media_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_media_state') + = f.input :status_media_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_media_threshold') + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_poll_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_poll_state') + = f.input :status_poll_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_poll_threshold') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_quote_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_quote_state') + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_reply_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_reply_state') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_mention_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_mention_state') + = f.input :status_mention_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_mention_threshold') + .fields-row__column.fields-row__column-6.fields-group + = f.input :status_reference_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_reference_state') + = f.input :status_reference_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_reference_threshold') + +%h4= t('admin.ng_rules.edit.headers.reaction') +%p.lead= t('admin.ng_rules.edit.summary.reaction') + +.fields-row + = f.input :reaction_allow_follower, wrapper: :with_label, hint: t('admin.ng_rules.reaction_allow_follower_hint'), label: t('admin.ng_rules.reaction_allow_follower') + +.fields-group + = f.input :reaction_type, wrapper: :with_block_label, collection: %i(favourite emoji_reaction reblog follow vote), as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("admin.ng_rules.reaction_types.#{context}") }, include_blank: false, label: t('admin.ng_rules.reaction_type') + +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :emoji_reaction_name, as: :text, input_html: { rows: 4 }, wrapper: :with_label, hint: false, label: t('admin.ng_rules.emoji_reaction_name') + .fields-row__column.fields-row__column-6.fields-group + = f.input :emoji_reaction_origin_domain, as: :text, input_html: { rows: 4 }, wrapper: :with_label, hint: t('admin.ng_rules.emoji_reaction_origin_domain_hint'), label: t('admin.ng_rules.emoji_reaction_origin_domain') + +%h4= t('admin.ng_rules.edit.headers.options') + +.fields-group + = f.input :available, wrapper: :with_label, label: t('admin.ng_rules.available'), hint: false + +.fields-group + = f.input :record_history_also_local, wrapper: :with_label, label: t('admin.ng_rules.record_history_also_local'), hint: false diff --git a/app/views/admin/ng_rules/edit.html.haml b/app/views/admin/ng_rules/edit.html.haml new file mode 100644 index 00000000000000..4d3990bcc31e2d --- /dev/null +++ b/app/views/admin/ng_rules/edit.html.haml @@ -0,0 +1,9 @@ +- content_for :page_title do + = t('admin.ng_rules.edit.title') + += simple_form_for @ng_rule, url: admin_ng_rule_path(@ng_rule), method: :put do |f| + = render 'shared/error_messages', object: @ng_rule + = render 'ng_rule_fields', f: f + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/ng_rules/index.html.haml b/app/views/admin/ng_rules/index.html.haml new file mode 100644 index 00000000000000..339cb0f7b633c4 --- /dev/null +++ b/app/views/admin/ng_rules/index.html.haml @@ -0,0 +1,14 @@ +- content_for :page_title do + = t('admin.ng_rules.index.title') + +- content_for :heading_actions do + = link_to t('admin.ng_rules.new.title'), new_admin_ng_rule_path, class: 'button' + +.simple_form + %p.lead= t('admin.ng_rules.index.preamble') + +- if @ng_rules.empty? + .muted-hint.center-text= t 'admin.ng_rules.index.empty' +- else + .applications-list + = render partial: 'ng_rule', collection: @ng_rules diff --git a/app/views/admin/ng_rules/new.html.haml b/app/views/admin/ng_rules/new.html.haml new file mode 100644 index 00000000000000..3c620e772450e5 --- /dev/null +++ b/app/views/admin/ng_rules/new.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('admin.ng_rules.new.title') + += simple_form_for @ng_rule, url: admin_ng_rules_path do |f| + = render 'ng_rule_fields', f: f + + .actions + = f.button :button, t('admin.ng_rules.new.save'), type: :submit diff --git a/app/views/admin/ng_words/show.html.haml b/app/views/admin/ng_words/show.html.haml index 7b76fe92da7b97..319b8c4477f5c3 100644 --- a/app/views/admin/ng_words/show.html.haml +++ b/app/views/admin/ng_words/show.html.haml @@ -7,6 +7,8 @@ = simple_form_for @admin_settings, url: admin_ng_words_path, html: { method: :post } do |f| = render 'shared/error_messages', object: @admin_settings + %p.lead= t('admin.ng_words.preamble') + %p.hint = t 'admin.ng_words.history_hint' = link_to t('admin.ngword_histories.title'), admin_ngword_histories_path @@ -14,9 +16,6 @@ .fields-group = f.input :ng_words_for_stranger_mention, wrapper: :with_label, as: :text, input_html: { rows: 10 }, label: t('admin.ng_words.keywords_for_stranger_mention'), hint: t('admin.ng_words.keywords_for_stranger_mention_hint') - .fields-group - = f.input :stranger_mention_from_local_ng, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.stranger_mention_from_local_ng'), hint: t('admin.ng_words.stranger_mention_from_local_ng_hint') - .fields-group = f.input :ng_words, wrapper: :with_label, as: :text, input_html: { rows: 10 }, label: t('admin.ng_words.keywords'), hint: t('admin.ng_words.keywords_hint') @@ -29,21 +28,28 @@ .fields-group = f.input :post_mentions_max, wrapper: :with_label, as: :integer, label: t('admin.ng_words.post_mentions_max') + %h4= t('admin.ng_words.white_list') + %p.lead + = t('admin.ng_words.white_list_hint') + = link_to t('admin.ng_words.remote_approval_list'), admin_accounts_path(status: 'remote_pending', origin: 'remote') + .fields-group - = f.input :hide_local_users_for_anonymous, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hide_local_users_for_anonymous') + = f.input :hold_remote_new_accounts, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hold_remote_new_accounts'), hint: t('admin.ng_words.remote_approval_hint') .fields-group - = f.input :block_unfollow_account_mention, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.block_unfollow_account_mention') + = f.input :permit_new_account_domains, wrapper: :with_label, as: :text, kmyblue: true, input_html: { rows: 6 }, label: t('admin.ng_words.permit_new_account_domains') - %p.hint - = t 'admin.ng_words.remote_approval_hint' - = link_to t('admin.ng_words.remote_approval_list'), admin_accounts_path(status: 'remote_pending', origin: 'remote') + %h4= t('admin.ng_words.deprecated') + %p.hint= t('admin.ng_words.deprecated_hint') + + .fields-group + = f.input :stranger_mention_from_local_ng, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.stranger_mention_from_local_ng'), hint: t('admin.ng_words.stranger_mention_from_local_ng_hint') .fields-group - = f.input :hold_remote_new_accounts, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hold_remote_new_accounts') + = f.input :hide_local_users_for_anonymous, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hide_local_users_for_anonymous'), hint: t('admin.ng_words.hide_local_users_for_anonymous_hint') .fields-group - = f.input :permit_new_account_domains, wrapper: :with_label, as: :text, kmyblue: true, input_html: { rows: 6 }, label: t('admin.ng_words.permit_new_account_domains') + = f.input :block_unfollow_account_mention, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.block_unfollow_account_mention'), hint: t('admin.ng_words.block_unfollow_account_mention_hint') .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/sensitive_words/show.html.haml b/app/views/admin/sensitive_words/show.html.haml index bb5447d3e1e9eb..dab3e8e1450fa1 100644 --- a/app/views/admin/sensitive_words/show.html.haml +++ b/app/views/admin/sensitive_words/show.html.haml @@ -4,16 +4,16 @@ - content_for :header_tags do = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' -%p.hint= t 'admin.sensitive_words.hint' - = simple_form_for @admin_settings, url: admin_sensitive_words_path, html: { method: :post } do |f| = render 'shared/error_messages', object: @admin_settings + %p.lead= t 'admin.sensitive_words.hint' + .fields-group - = f.input :sensitive_words_for_full, wrapper: :with_label, as: :text, input_html: { rows: 12 }, label: t('admin.sensitive_words.keywords_for_all'), hint: t('admin.sensitive_words.keywords_for_all_hint') + = f.input :sensitive_words_for_full, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords_for_all'), hint: t('admin.sensitive_words.keywords_for_all_hint') .fields-group - = f.input :sensitive_words, wrapper: :with_label, as: :text, input_html: { rows: 12 }, label: t('admin.sensitive_words.keywords'), hint: t('admin.sensitive_words.keywords_hint') + = f.input :sensitive_words, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords'), hint: t('admin.sensitive_words.keywords_hint') .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/workers/scheduler/vacuum_scheduler.rb b/app/workers/scheduler/vacuum_scheduler.rb index eb4d2f5b10a2d5..5c6e74e3d453c9 100644 --- a/app/workers/scheduler/vacuum_scheduler.rb +++ b/app/workers/scheduler/vacuum_scheduler.rb @@ -26,6 +26,7 @@ def vacuum_operations feeds_vacuum, imports_vacuum, list_statuses_vacuum, + ng_histories_vacuum, ] end @@ -65,6 +66,10 @@ def applications_vacuum Vacuum::ApplicationsVacuum.new end + def ng_histories_vacuum + Vacuum::NgHistoriesVacuum.new + end + def content_retention_policy ContentRetentionPolicy.current end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1e022e7e55a033..3e9223865fb47a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -647,9 +647,108 @@ en: title: IP rules media_attachments: title: Media attachments + ng_rules: + account_avatar_state: Has avatar or not + account_display_name: Name + account_domain: Domain + account_field_name: Field name + account_field_value: Field value + account_header_state: Has header or not + account_include_local: Contains local users + account_note: Account note + account_username: ID + copy: Copy + copy_error: Copy failed. + edit: + headers: + account: Account + options: Options + reaction: Reaction + status: Post + helps: + generic: Restrictions will be in effect only if all items match the criteria. + textarea_html: Where multiple lines can be entered, enter conditions separated by line breaks. If any one of the multiple lines is included, the condition is considered a match. You can use a regular expression by starting a line with "?" at the beginning of a line to use a regular expression. + threshold_html: In specifying the upper limit, specifying "-1" disables the upper limit check. If "0" is specified, the check will pass only if the subject is completely absent. For example, an upper limit of "0" for the number of media is not equivalent to "no media. + history: Check the history of this rule's application. + summary: + account: Set the conditions for the account. This NG rule will be checked when an account matching here posts and reacts. No check is performed when an account is created. By default, all accounts are subject to this rule. + reaction: Set the reaction conditions. The account conditions must match at the same time. Please note that by default, not all reactions will match unless you set the "Reaction Type". + status: Set the conditions of your submission. The account conditions must match at the same time. Please note that by default, not all posts will be applicable unless you set the "Visibility" and "Searchability". + title: Edit NG Rule + index: + delete: Delete + disabled: Disabled + edit: + title: Edit + empty: Empty NG rules + empty_title: Empty title + hit_count: "%{count} hits on this week" + preamble: While NG words are sufficient to deal with normal spam, some of these words must be specified too loosely, making it easier for other normal postings to get caught up in the restrictions. You can also specify the details of the account and the characteristics of the post in the NG rule. NG rules also support the prevention of spam using reactions. + title: NG Rules + new: + save: Save new NG rule + title: Add new NG Rule + reaction_type: Reaction type + reaction_types: + emoji_reaction: Emoji reaction + favourite: Favourite + follow: Follow + reblog: Boost + vote: Vote + reaction_allow_follower: Allow all reactions targeted at followers + reaction_allow_follower_hint: If enabled, reactions between other servers are unconditionally allowed + record_history_also_local: Local users are also subject to history recording + rubular: Regular Expression Checker + states: + needed: have + no_needed: Should not have + optional: Optional + status_allow_follower_mention: Check posts only if they contain mentions/references to non-followers + status_allow_follower_mention_hint: If enabled, mentions between other servers are unconditionally allowed + status_cw_state: Has warning or not + status_media_state: Has media or not + status_media_threshold: Medias limit + status_mention_state: Has mention or not + status_mention_threshold: Mentions limit + status_poll_state: Has poll or not + status_poll_threshold: Poll items limit + status_quote_state: Has quote or not + status_reference_state: Has reference or not + status_reference_threshold: Has reference or not + status_reply_state: Is reply or not + status_searchability: Searchability + status_sensitive_state: Is sensitive or not + status_spoiler_text: Content warning + status_tag: Tag + status_text: Post text + status_visibility: Visibility + test_error: Regular expression syntax is incorrect. + title: NG Rule + ng_rule_histories: + back_to_ng_rule: Back to NG rule + back_to_ng_rules: Back to index + data: + media_count: "%{count} medias" + poll_count: "%{count} polls" + from_local_user: Local user + hidden: Private post + moderate_account: Moderate account + reason_actions: + reaction_emoji_reaction: Emoji reaction + reaction_favourite: Favourite + reaction_follow: Follow request + reaction_reblog: Boost + reaction_vote: Vote + status_create: Post + status_edit: Edit post + title: NG Rule History %{title} ng_words: block_unfollow_account_mention: Reject all mentions/references from all accounts that do not have followers on your server + block_unfollow_account_mention_hint: この設定は削除予定です。設定削除後は、常にチェックをつけていない場合と同じ挙動になります。NGルールで代替してください。 + deprecated: Will remove settings + deprecated_hint: These settings will be removed in the next LTS or kmyblue version 14.0, whichever comes first. Please refer to the description of each setting and replace them with the new settings if possible. hide_local_users_for_anonymous: Hide timeline local user posts from anonymous + hide_local_users_for_anonymous_hint: この設定は削除予定です。設定削除後は、常にチェックをつけていない場合と同じ挙動になります。サーバー設定の「見つける」にある「公開タイムラインへの未認証のアクセスを許可する」で、完全ではありませんが代替可能です。 history_hint: We recommend that you regularly check your NG words to make sure that you have not specified the NG words incorrectly. hold_remote_new_accounts: Hold new remote accounts keywords: Reject keywords @@ -657,15 +756,18 @@ en: keywords_for_stranger_mention_hint: This words are checked posts from other servers only. keywords_hint: The first character of the line is "?". to use regular expressions permit_new_account_domains: Domain list to automatically approve new users + preamble: This setting is very useful when you receive spam or harassment from many servers, or when you encounter other problems that cannot be solved by domain blocking. You can reject posts received from other servers or within your own server that contain specific keywords or too many mentions. Please consider this setting carefully to ensure that posts that are not problematic are not deleted. Rejected posts will be recorded in your history unless they are limited posts. If you need more detailed settings, please use another feature, "NG Rules". post_hash_tags_max: Hash tags max for posts post_mentions_max: Mentions max for posts post_stranger_mentions_max: 投稿に設定可能なメンションの最大数 (If the mentions include at least one person who is not a follower of yours) remote_approval_list: List of remote accounts awaiting approval remote_approval_hint: Newly recognized accounts with unspecified domains will be placed in Suspended status. You can review that list and approve them if necessary. If this setting is not enabled, all remote accounts will be approved immediately. stranger_mention_from_local_ng: フォローしていないアカウントへのメンションのNGワードを、ローカルユーザーによる投稿にも適用する - stranger_mention_from_local_ng_hint: サーバーの登録が承認制でない場合、あなたのサーバーにもスパムが入り込む可能性があります + stranger_mention_from_local_ng_hint: この設定は削除予定です。設定削除後は、常にチェックをつけている場合と同じ挙動になります。この動作を希望しない場合は、NGルールで代替してください。 test_error: Testing is returned any errors title: NG words and against spams + white_list: White list + white_list_hint: Unlike a normal whitelist, all previous accounts are marked as trusted, and all newly recognized accounts are placed on hold. Activating the whitelist does not immediately eliminate external attacks, but it can gradually and surely reduce them through moderation. In addition, a periodic remote account approval process is required. ngword_histories: back_to_ng_words: NG words and against spams target_types: @@ -856,9 +958,9 @@ en: title: Server rules sensitive_words: alert: This post contains sensitive words, so alert added - hint: This keywords is applied to public posts only.. - keywords: Sensitive keywords - keywords_for_all: Sensitive keywords (Contains CW alert) + hint: The sensitive keywords setting is applied to the "Public", "Local Public", and "Logged-in Users Only" public ranges that flow through the local timeline. A mandatory warning text will be added to any post that meets the criteria. + keywords: Sensitive keywords for local posts + keywords_for_all: Sensitive keywords for local posts (Contains CW alert) keywords_for_all_hint: The first character of the line is "?". to use regular expressions title: Sensitive words and moderation options settings: @@ -1954,6 +2056,7 @@ en: public_search_long: You can search all posts permitted to search public_unlisted: Local and followers public_unlisted_long: Local users and followers can find + unset: (Unsupported servers) show_more: Show more show_newer: Show newer show_older: Show older @@ -1961,8 +2064,10 @@ en: title: '%{name}: "%{quote}"' too_many_hashtags: Too many hashtags too_many_mentions: Too many mentions + violate_rules: Violate NG rules visibilities: direct: Direct + limited: Limited login: Login only login_long: Only logined users private: Followers-only diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 30465716db58d0..a497bdcf248327 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -640,9 +640,114 @@ ja: title: IPルール media_attachments: title: 投稿された画像 + ng_rules: + account_allow_followed_by_local: ローカルユーザーからフォローされていないアカウントのみチェックする + account_allow_followed_by_local_hint: 1以上のフォローを持つ全てのローカルユーザーが信頼できる場合にのみこのオプションを使用してください + account_avatar_state: アイコンの有無 + account_display_name: 名前 + account_domain: ドメイン + account_field_name: フィールド名 + account_field_value: フィールド値 + account_header_state: ヘッダーの有無 + account_include_local: ローカルユーザーを含む + account_note: アカウントの説明文 + account_username: ID + available: 有効にする + copy: 複製 + copy_error: 複製に失敗しました。 + edit: + headers: + account: アカウント + options: オプション + reaction: リアクション + status: 投稿 + helps: + generic: 全ての項目が条件にマッチしている場合のみ、制限が有効になります。 + textarea_html: 複数行入力可能な箇所では、改行区切りで条件を入力します。複数行のうちどれか1つが含まれている場合、条件にマッチしたと判断されます。行頭を「?」で始めると正規表現を利用できます。 + threshold_html: 上限の指定では、「-1」を指定すると上限チェックが無効になります。「0」を指定した場合、その対象が全く無い状態に限りチェックを通過します。例えばメディア数の上限「0」は、「メディア無し」と等価ではありません。 + history: このルールが適用された履歴を確認する + summary: + account: アカウントの条件を設定します。ここでマッチしたアカウントが投稿・リアクションする場合に、このNGルールのチェックが行われます。アカウント作成時のチェックは行いません。初期状態では全てのアカウントが対象になります。 + reaction: リアクションの条件を設定します。アカウントの条件も同時に一致しなければいけません。初期状態では、「リアクションの種類」を設定しない限り全てのリアクションが該当しませんので、ご注意ください。 + status: 投稿の条件を設定します。アカウントの条件も同時に一致しなければいけません。初期状態では、「公開範囲」「検索許可」を設定しない限り全ての投稿が該当しませんので、ご注意ください。 + title: NGルールを編集 + emoji_reaction_name: 絵文字リアクションで使われる絵文字またはショートコード + emoji_reaction_origin_domain: 絵文字リアクションで使われたカスタム絵文字のドメイン + emoji_reaction_origin_domain_hint: 他のサーバーの絵文字に相乗りした場合、その絵文字がもともと登録されていたサーバーのドメインが使用されます + index: + delete: 削除 + disabled: 無効 + edit: + title: 編集 + empty: NGルールが空です + empty_title: 空のタイトル + hit_count: ここ一週間で %{count} 件の検出 + preamble: 通常のスパムへの対応にはNGワードで十分ですが、中にはどうしても緩すぎる条件を指定しなければならず、他の正常な投稿が規制に巻き込まれやすくなる場合があります。NGルールにおいてアカウントや投稿の特徴などを詳細に指定して、巻き込まれる投稿を少しでも減らすことができます。また、リアクションを使用したスパムの防止もサポートしています。 + title: NGルール + new: + save: 新規NGルールを保存 + title: 新規NGルールを追加 + reaction_allow_follower: フォロワー以外に対するリアクションのみチェックする + reaction_allow_follower_hint: これを有効にすると、他のサーバー同士のリアクションは無条件で許可されます + reaction_type: リアクションの種類 + reaction_types: + emoji_reaction: 絵文字リアクション + favourite: お気に入り + follow: フォロー + reblog: ブースト + vote: 投票 + record_history_also_local: ローカルユーザーも履歴記録の対象とする + rubular: 正規表現チェッカー + states: + needed: 有り + no_needed: 無し + optional: 不問 + status_allow_follower_mention: フォロワー以外へのメンション・参照を含む場合のみ投稿をチェックする + status_allow_follower_mention_hint: これを有効にすると、他のサーバー同士のメンションは無条件で許可されます + status_cw_state: 警告文の有無 + status_media_state: メディアの有無 + status_media_threshold: メディア数の上限 + status_mention_state: メンションの有無 + status_mention_threshold: メンション数の上限 + status_poll_state: 投票の有無 + status_poll_threshold: 投票項目数の上限 + status_quote_state: 引用の有無 + status_reference_state: 参照の有無 + status_reference_threshold: 参照数の上限 + status_reply_state: 返信の有無 + status_searchability: 検索許可 + status_sensitive_state: センシティブフラグの有無 + status_spoiler_text: 投稿警告文 + status_tag: タグ + status_text: 投稿本文 + status_visibility: 公開範囲 + test_error: 正規表現の文法が誤っています + title: NGルール + ng_rule_histories: + back_to_ng_rule: NGルール設定に戻る + back_to_ng_rules: 一覧に戻る + data: + media_count: "%{count} のメディア" + poll_count: 項目 %{count} の投票 + from_local_user: ローカルユーザー + hidden: 非公開投稿 + moderate_account: アカウントをモデレートする + reason_actions: + reaction_emoji_reaction: 絵文字リアクション + reaction_favourite: お気に入りに登録 + reaction_follow: フォローリクエスト + reaction_reblog: ブースト + reaction_vote: 投票 + status_create: 投稿 + status_edit: 投稿を編集 + title: NGルール「%{title}」の履歴 ng_words: block_unfollow_account_mention: 自分のサーバーのフォロワーを持たない全てのアカウントからのメンション・参照を全て拒否する + block_unfollow_account_mention_hint: この設定は削除予定です。設定削除後は、常にチェックをつけていない場合と同じ挙動になります。NGルールで代替してください。 + deprecated: 新しいバージョンで削除する予定の設定 + deprecated_hint: これらの設定は、次回のLTS、またはkmyblueバージョン14.0のどちらか早い方で削除する予定です。それぞれの設定の説明を参照して、可能であれば新しい設定に置き換えてください。 hide_local_users_for_anonymous: ログインしていない状態でローカルユーザーの投稿をタイムラインから取得できないようにする + hide_local_users_for_anonymous_hint: この設定は削除予定です。設定削除後は、常にチェックをつけていない場合と同じ挙動になります。サーバー設定の「見つける」にある「公開タイムラインへの未認証のアクセスを許可する」で、完全ではありませんが代替可能です。 history_hint: 設定されたNGワードによって実際に拒否された投稿などは、履歴より確認できます。NGワードの指定に誤りがないか定期的に確認することをおすすめします。 hold_remote_new_accounts: リモートの新規アカウントを保留する keywords: 投稿できないキーワード @@ -650,15 +755,18 @@ ja: keywords_for_stranger_mention_hint: フォローしていないアカウントへのメンション、参照、引用にのみ適用されます keywords_hint: 行を「?」で始めると、正規表現が使えます permit_new_account_domains: 新規ユーザーを自動承認するドメイン + preamble: スパムや嫌がらせを多くのサーバーから受け取った場合など、ドメインブロックで解決できない問題が発生した場合にこの設定が非常に役に立ちます。他のサーバーからの受信や自分のサーバー内で、特定キーワードが含まれていたり、メンション数が多すぎたりする投稿を拒否することができます。問題のない投稿が削除されないよう、設定は慎重に検討してください。拒否された投稿は、限定投稿でない限り履歴に記録されます。さらに細かい設定が必要な場合は、別の機能である「NGルール」をお使いください。 post_hash_tags_max: 投稿に設定可能なハッシュタグの最大数 post_mentions_max: 投稿に設定可能なメンションの最大数 post_stranger_mentions_max: 投稿に設定可能なメンションの最大数 (メンション先にフォロワー以外を1人でも含む場合) remote_approval_list: 承認待ちのリモートアカウント一覧 remote_approval_hint: 指定されていないドメインで新しく認識されたアカウントはサスペンド状態になります。その一覧を確認し、必要であれば承認を行うことができます。この設定が有効でない場合、全てのリモートアカウントが即座に承認されます。 stranger_mention_from_local_ng: フォローしていないアカウントへのメンションのNGワードを、ローカルユーザーによる投稿にも適用する - stranger_mention_from_local_ng_hint: サーバーの登録が承認制でない場合、あなたのサーバーにもスパムが入り込む可能性があります + stranger_mention_from_local_ng_hint: この設定は削除予定です。設定削除後は、常にチェックをつけている場合と同じ挙動になります。この動作を希望しない場合は、NGルールで代替してください。 test_error: NGワードのテストに失敗しました。正規表現のミスが含まれているかもしれません title: NGワードとスパム + white_list: ホワイトリスト + white_list_hint: 通常のホワイトリストとは異なり、これまでのアカウントは全て信頼済としてマークされ、新たに認識されるアカウントが全て保留中となります。ホワイトリストを有効にしたところで外部からの攻撃が即座に消えるわけではありませんが、モデレーションを進めることで徐々に確実に減らすことができます。また、定期的なリモートアカウント承認作業が求められます。 ngword_histories: back_to_ng_words: NGワードとスパム target_types: @@ -846,9 +954,9 @@ ja: title: サーバーのルール sensitive_words: alert: この投稿にはセンシティブなキーワードが含まれるため、警告文が追加されました - hint: センシティブなキーワードの設定は、当サーバーのローカルユーザーによる公開範囲「公開」「ローカル公開」「ログインユーザーのみ」に対して適用されます。 - keywords: センシティブなキーワード(警告文は除外) - keywords_for_all: センシティブなキーワード(警告文にも適用) + hint: センシティブなキーワードの設定は、ローカルタイムラインを流れる公開範囲「公開」「ローカル公開」「ログインユーザーのみ」に対して適用されます。条件に該当した投稿には強制的に警告文章が追加されます。 + keywords: ローカル投稿のみに適用するセンシティブなキーワード(警告文は除外) + keywords_for_all: ローカル投稿のみに適用するセンシティブなキーワード(警告文にも適用) keywords_for_all_hint: ここで指定したキーワードを含む投稿は強制的にCWになります。警告文にも含まれていればCWになります。行が「?」で始まっていれば正規表現が使えます keywords_hint: ここで指定したキーワードを含む投稿は強制的にCWになります。ただし警告文に使用していた場合は無視されます title: センシティブ単語と設定 @@ -1927,6 +2035,7 @@ ja: public_search_long: 検索が許可された全ての投稿が検索できます public_unlisted: ローカルとフォロワー public_unlisted_long: ローカル・フォロワー・反応者のみが検索できます + unset: (未対応サーバー) show_more: もっと見る show_newer: 新しいものを表示 show_older: 古いものを表示 @@ -1934,8 +2043,10 @@ ja: title: '%{name}: "%{quote}"' too_many_hashtags: ハッシュタグが多すぎます too_many_mentions: メンションが多すぎます + violate_rules: サーバールールに違反するため投稿できません visibilities: direct: ダイレクト + limited: 限定投稿 login: ログインユーザーのみ login_long: ログインしたユーザーのみが見ることができます private: フォロワー限定 diff --git a/config/navigation.rb b/config/navigation.rb index 00d048d0392725..0f1c8818db457e 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -50,6 +50,7 @@ s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_path, highlights_on: %r{/admin/reports}, if: -> { current_user.can?(:manage_reports) } s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_path(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes|/admin/users}, if: -> { current_user.can?(:manage_users) } s.item :ng_words, safe_join([fa_icon('list fw'), t('admin.ng_words.title')]), admin_ng_words_path, highlights_on: %r{/admin/(ng_words|ngword_histories)}, if: -> { current_user.can?(:manage_ng_words) } + s.item :ng_rules, safe_join([fa_icon('rub fw'), t('admin.ng_rules.title')]), admin_ng_rules_path, highlights_on: %r{/admin/(ng_rules|ng_rule_histories)}, if: -> { current_user.can?(:manage_ng_words) } s.item :sensitive_words, safe_join([fa_icon('list fw'), t('admin.sensitive_words.title')]), admin_sensitive_words_path, highlights_on: %r{/admin/sensitive_words}, if: -> { current_user.can?(:manage_sensitive_words) } s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path, if: -> { current_user.can?(:manage_invites) } s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}, if: -> { current_user.can?(:manage_taxonomies) } diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 7cf4f1d29c44f2..64636957b1aca6 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -34,6 +34,12 @@ resources :warning_presets, except: [:new, :show] resource :ng_words, only: [:show, :create] resources :ngword_histories, only: [:index] + resources :ng_rules, except: [:show] do + member do + post :duplicate + end + end + resources :ng_rule_histories, only: [:show] resource :sensitive_words, only: [:show, :create] resource :special_instances, only: [:show, :create] diff --git a/db/migrate/20240218233621_create_ng_rules.rb b/db/migrate/20240218233621_create_ng_rules.rb new file mode 100644 index 00000000000000..55c2f138092d2f --- /dev/null +++ b/db/migrate/20240218233621_create_ng_rules.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class CreateNgRules < ActiveRecord::Migration[7.1] + def change + create_table :ng_rules do |t| + t.string :title, null: false, default: '' + t.boolean :available, null: false, default: true + t.boolean :record_history_also_local, null: false, default: true + t.string :account_domain, null: false, default: '' + t.string :account_username, null: false, default: '' + t.string :account_display_name, null: false, default: '' + t.string :account_note, null: false, default: '' + t.string :account_field_name, null: false, default: '' + t.string :account_field_value, null: false, default: '' + t.integer :account_avatar_state, null: false, default: 0 + t.integer :account_header_state, null: false, default: 0 + t.boolean :account_include_local, null: false, default: true + t.boolean :account_allow_followed_by_local, null: false, default: false + t.string :status_spoiler_text, null: false, default: '' + t.string :status_text, null: false, default: '' + t.string :status_tag, null: false, default: '' + t.string :status_visibility, null: false, default: [], array: true + t.string :status_searchability, null: false, default: [], array: true + t.integer :status_media_state, null: false, default: 0 + t.integer :status_sensitive_state, null: false, default: 0 + t.integer :status_cw_state, null: false, default: 0 + t.integer :status_poll_state, null: false, default: 0 + t.integer :status_quote_state, null: false, default: 0 + t.integer :status_reply_state, null: false, default: 0 + t.integer :status_mention_state, null: false, default: 0 + t.integer :status_reference_state, null: false, default: 0 + t.integer :status_tag_threshold, null: false, default: -1 + t.integer :status_media_threshold, null: false, default: -1 + t.integer :status_poll_threshold, null: false, default: -1 + t.integer :status_mention_threshold, null: false, default: -1 + t.boolean :status_allow_follower_mention, null: false, default: true + t.integer :status_reference_threshold, null: false, default: -1 + t.string :reaction_type, null: false, default: [], array: true + t.boolean :reaction_allow_follower, null: false, default: true + t.string :emoji_reaction_name, null: false, default: '' + t.string :emoji_reaction_origin_domain, null: false, default: '' + t.datetime :expires_at + + t.timestamps + end + + create_table :ng_rule_histories do |t| + t.belongs_to :ng_rule, null: false, foreign_key: { on_cascade: :delete }, index: false + t.belongs_to :account, foreign_key: { on_cascade: :nullify }, index: false + t.string :text + t.string :uri, index: true + t.integer :reason, null: false + t.integer :reason_action, null: false + t.boolean :local, null: false, default: true + t.boolean :hidden, null: false, default: false + t.jsonb :data + + t.timestamps + end + + add_index :ng_rule_histories, [:ng_rule_id, :account_id] + add_index :ng_rule_histories, :created_at + end +end diff --git a/db/schema.rb b/db/schema.rb index fc4b2843e21b8f..5f8e87bf0bc41d 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[7.1].define(version: 2024_02_17_230006) do +ActiveRecord::Schema[7.1].define(version: 2024_02_18_233621) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -870,6 +870,65 @@ t.index ["target_account_id"], name: "index_mutes_on_target_account_id" end + create_table "ng_rule_histories", force: :cascade do |t| + t.bigint "ng_rule_id", null: false + t.bigint "account_id" + t.string "text" + t.string "uri" + t.integer "reason", null: false + t.integer "reason_action", null: false + t.boolean "local", default: true, null: false + t.boolean "hidden", default: false, null: false + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_ng_rule_histories_on_created_at" + t.index ["ng_rule_id", "account_id"], name: "index_ng_rule_histories_on_ng_rule_id_and_account_id" + t.index ["uri"], name: "index_ng_rule_histories_on_uri" + end + + create_table "ng_rules", force: :cascade do |t| + t.string "title", default: "", null: false + t.boolean "available", default: true, null: false + t.boolean "record_history_also_local", default: true, null: false + t.string "account_domain", default: "", null: false + t.string "account_username", default: "", null: false + t.string "account_display_name", default: "", null: false + t.string "account_note", default: "", null: false + t.string "account_field_name", default: "", null: false + t.string "account_field_value", default: "", null: false + t.integer "account_avatar_state", default: 0, null: false + t.integer "account_header_state", default: 0, null: false + t.boolean "account_include_local", default: true, null: false + t.boolean "account_allow_followed_by_local", default: false, null: false + t.string "status_spoiler_text", default: "", null: false + t.string "status_text", default: "", null: false + t.string "status_tag", default: "", null: false + t.string "status_visibility", default: [], null: false, array: true + t.string "status_searchability", default: [], null: false, array: true + t.integer "status_media_state", default: 0, null: false + t.integer "status_sensitive_state", default: 0, null: false + t.integer "status_cw_state", default: 0, null: false + t.integer "status_poll_state", default: 0, null: false + t.integer "status_quote_state", default: 0, null: false + t.integer "status_reply_state", default: 0, null: false + t.integer "status_mention_state", default: 0, null: false + t.integer "status_reference_state", default: 0, null: false + t.integer "status_tag_threshold", default: -1, null: false + t.integer "status_media_threshold", default: -1, null: false + t.integer "status_poll_threshold", default: -1, null: false + t.integer "status_mention_threshold", default: -1, null: false + t.boolean "status_allow_follower_mention", default: true, null: false + t.integer "status_reference_threshold", default: -1, null: false + t.string "reaction_type", default: [], null: false, array: true + t.boolean "reaction_allow_follower", default: true, null: false + t.string "emoji_reaction_name", default: "", null: false + t.string "emoji_reaction_origin_domain", default: "", null: false + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "ngword_histories", force: :cascade do |t| t.string "uri", null: false t.integer "target_type", null: false @@ -1547,6 +1606,8 @@ add_foreign_key "mentions", "statuses", on_delete: :cascade add_foreign_key "mutes", "accounts", column: "target_account_id", name: "fk_eecff219ea", on_delete: :cascade add_foreign_key "mutes", "accounts", name: "fk_b8d8daf315", on_delete: :cascade + add_foreign_key "ng_rule_histories", "accounts" + add_foreign_key "ng_rule_histories", "ng_rules" add_foreign_key "notifications", "accounts", column: "from_account_id", name: "fk_fbd6b0bf9e", on_delete: :cascade add_foreign_key "notifications", "accounts", name: "fk_c141c8ee55", on_delete: :cascade add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id", name: "fk_34d54b0a33", on_delete: :cascade diff --git a/lib/tasks/dangerous.rake b/lib/tasks/dangerous.rake index 30ba86fa231fd1..000be50911e434 100644 --- a/lib/tasks/dangerous.rake +++ b/lib/tasks/dangerous.rake @@ -88,6 +88,7 @@ namespace :dangerous do 20240217022038 20240217093511 20240217230006 + 20240218233621 ) # Removed: account_groups target_tables = %w( @@ -104,6 +105,8 @@ namespace :dangerous do friend_domains instance_infos list_statuses + ng_rules + ng_rule_histories ngword_histories pending_follow_requests scheduled_expiration_statuses diff --git a/spec/fabricators/ng_rule_fabricator.rb b/spec/fabricators/ng_rule_fabricator.rb new file mode 100644 index 00000000000000..f448c22c08209a --- /dev/null +++ b/spec/fabricators/ng_rule_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:ng_rule) do + status_visibility %w(public) + status_searchability %w(direct unset) + reaction_type %w(favourite) +end diff --git a/spec/fabricators/ng_rule_history_fabricator.rb b/spec/fabricators/ng_rule_history_fabricator.rb new file mode 100644 index 00000000000000..75c40880da8ef4 --- /dev/null +++ b/spec/fabricators/ng_rule_history_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:ng_rule_history) do + ng_rule { Fabricate.build(:ng_rule) } + account { Fabricate.build(:account) } + reason 0 + reason_action 0 +end diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb index b556bfd6c2ece3..45059645745051 100644 --- a/spec/lib/activitypub/activity/announce_spec.rb +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -111,6 +111,38 @@ end end + context 'when ng rule is existing' do + context 'when ng rule is match' do + before do + Fabricate(:ng_rule, account_domain: 'example.com', reaction_type: ['reblog']) + subject.perform + end + + let(:object_json) do + ActivityPub::TagManager.instance.uri_for(status) + end + + it 'does not create a reblog by sender of status' do + expect(sender.reblogged?(status)).to be false + end + end + + context 'when ng rule is not match' do + before do + Fabricate(:ng_rule, account_domain: 'foo.bar', reaction_type: ['reblog']) + subject.perform + end + + let(:object_json) do + ActivityPub::TagManager.instance.uri_for(status) + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end + end + context 'when the sender is relayed' do subject { described_class.new(json, sender, relayed_through_actor: relay_account) } diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index e1103e33973ec5..4b10224bb6d6cd 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -1560,6 +1560,32 @@ def activity_for_object(json) expect(vote.uri).to eq object_json[:id] expect(poll.reload.cached_tallies).to eq [1, 0] end + + context 'when ng rule is existing' do + let(:custom_before) { true } + + context 'when ng rule is match' do + before do + Fabricate(:ng_rule, account_domain: 'example.com', reaction_type: ['vote']) + subject.perform + end + + it 'does not create a reblog by sender of status' do + expect(poll.votes.first).to be_nil + end + end + + context 'when ng rule is not match' do + before do + Fabricate(:ng_rule, account_domain: 'foo.bar', reaction_type: ['vote']) + subject.perform + end + + it 'creates a reblog by sender of status' do + expect(poll.votes.first).to_not be_nil + end + end + end end context 'when a vote to an expired local poll' do @@ -2024,6 +2050,43 @@ def activity_for_object(json) end end + context 'when ng rule is set' do + let(:custom_before) { true } + let(:content) { 'Lorem ipsum' } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: content, + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + context 'when rule hits' do + before do + Fabricate(:ng_rule, status_text: 'ipsum', status_allow_follower_mention: false) + subject.perform + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to be_nil + end + end + + context 'when rule does not hit' do + before do + Fabricate(:ng_rule, status_text: 'amely', status_allow_follower_mention: false) + subject.perform + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + end + end + end + context 'when hashtags limit is set' do let(:post_hash_tags_max) { 2 } let(:custom_before) { true } diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb index ecb41ef25be8a4..c04945b76590a4 100644 --- a/spec/lib/activitypub/activity/follow_spec.rb +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -244,6 +244,33 @@ expect(sender.requested?(recipient)).to be false end end + + context 'when ng rule is existing' do + context 'when ng rule is match' do + before do + Fabricate(:ng_rule, account_domain: 'example.com', reaction_type: ['follow']) + stub_request(:post, 'https://example.com/inbox').to_return(status: 200, body: '', headers: {}) + subject.perform + end + + it 'does not create a reblog by sender of status' do + expect(sender.following?(recipient)).to be false + expect(sender.requested?(recipient)).to be false + end + end + + context 'when ng rule is not match' do + before do + Fabricate(:ng_rule, account_domain: 'foo.bar', reaction_type: ['follow']) + subject.perform + end + + it 'creates a reblog by sender of status' do + expect(sender.following?(recipient)).to be true + expect(sender.requested?(recipient)).to be false + end + end + end end context 'when a follow relationship already exists' do diff --git a/spec/lib/activitypub/activity/like_spec.rb b/spec/lib/activitypub/activity/like_spec.rb index 2679f3dfb8a794..f255bc955e3f9e 100644 --- a/spec/lib/activitypub/activity/like_spec.rb +++ b/spec/lib/activitypub/activity/like_spec.rb @@ -55,6 +55,32 @@ end end + context 'when ng rule is existing' do + subject { described_class.new(json, sender) } + + context 'when ng rule is match' do + before do + Fabricate(:ng_rule, account_domain: 'example.com', reaction_type: ['favourite']) + subject.perform + end + + it 'does not create a reblog by sender of status' do + expect(sender.favourited?(status)).to be false + end + end + + context 'when ng rule is not match' do + before do + Fabricate(:ng_rule, account_domain: 'foo.bar', reaction_type: ['favourite']) + subject.perform + end + + it 'creates a reblog by sender of status' do + expect(sender.favourited?(status)).to be true + end + end + end + describe '#perform when receive emoji reaction' do subject do described_class.new(json, sender).perform @@ -592,6 +618,30 @@ end end end + + context 'when ng rule is existing' do + let(:content) { '😀' } + + context 'when ng rule is match' do + before do + Fabricate(:ng_rule, account_domain: 'example.com', reaction_type: ['emoji_reaction']) + end + + it 'does not create a reblog by sender of status' do + expect(subject.count).to eq 0 + end + end + + context 'when ng rule is not match' do + before do + Fabricate(:ng_rule, account_domain: 'foo.bar', reaction_type: ['emoji_reaction']) + end + + it 'creates a reblog by sender of status' do + expect(subject.count).to eq 1 + end + end + end end describe '#perform when rejecting favourite domain block' do diff --git a/spec/lib/vacuum/ng_histories_vacuum_spec.rb b/spec/lib/vacuum/ng_histories_vacuum_spec.rb new file mode 100644 index 00000000000000..d75d7f353b3dad --- /dev/null +++ b/spec/lib/vacuum/ng_histories_vacuum_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Vacuum::NgHistoriesVacuum do + subject { described_class.new } + + describe '#perform' do + let!(:rule_history_old) { Fabricate(:ng_rule_history, created_at: 30.days.ago) } + let!(:rule_history_recent) { Fabricate(:ng_rule_history, created_at: 2.days.ago) } + + before do + subject.perform + end + + it 'deletes old history' do + expect { rule_history_old.reload }.to raise_error ActiveRecord::RecordNotFound + end + + it 'does not delete recent history' do + expect { rule_history_recent.reload }.to_not raise_error + end + end +end diff --git a/spec/models/admin/ng_rule_spec.rb b/spec/models/admin/ng_rule_spec.rb new file mode 100644 index 00000000000000..8e04bc16966d0d --- /dev/null +++ b/spec/models/admin/ng_rule_spec.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::NgRule do + shared_examples 'matches rule' do |reason| + it 'matches and history is added' do + expect(subject).to be false + + history = NgRuleHistory.order(id: :desc).find_by(ng_rule: ng_rule) + expect(history).to_not be_nil + expect(history.account_id).to eq account.id + expect(history.reason).to eq reason + expect(history.uri).to eq uri + end + end + + shared_examples 'does not match rule' do + it 'does not match and history is not added' do + expect(subject).to be true + + history = NgRuleHistory.order(id: :desc).find_by(ng_rule: ng_rule) + expect(history).to be_nil + end + end + + shared_examples 'check all states' do |reason, results| + context 'when rule state is optional' do + let(:state) { :optional } + + it_behaves_like results[0] ? 'does not match rule' : 'matches rule', reason + end + + context 'when rule state is needed' do + let(:state) { :needed } + + it_behaves_like results[1] ? 'does not match rule' : 'matches rule', reason + end + + context 'when rule state is no_needed' do + let(:state) { :no_needed } + + it_behaves_like results[2] ? 'does not match rule' : 'matches rule', reason + end + end + + let(:uri) { 'https://example.com/operation' } + + describe '#check_account_or_record!' do + subject { described_class.new(ng_rule, account).check_account_or_record! } + + context 'when unmatch rule' do + let(:ng_rule) { Fabricate(:ng_rule, account_note: 'assur', account_include_local: true) } + let(:account) { Fabricate(:account, domain: 'example.com', uri: uri) } + + it_behaves_like 'does not match rule' + end + + context 'with domain rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: uri) } + let(:ng_rule) { Fabricate(:ng_rule, account_domain: '?example\..*') } + + it_behaves_like 'matches rule', 'account' + end + + context 'with note rule' do + let(:uri) { '' } + let(:account) { Fabricate(:account, note: 'ohagi is good') } + let(:ng_rule) { Fabricate(:ng_rule, account_note: 'ohagi', account_include_local: true) } + + it_behaves_like 'matches rule', 'account' + end + + context 'with display name rule' do + let(:uri) { '' } + let(:account) { Fabricate(:account, display_name: '') } + let(:ng_rule) { Fabricate(:ng_rule, account_display_name: "?^$\r\n?[a-z0-9]{10}", account_include_local: true) } + + it_behaves_like 'matches rule', 'account' + end + + context 'with field name rule' do + let(:account) { Fabricate(:account, fields_attributes: { '0' => { name: 'Name', value: 'Value' } }, domain: 'example.com', uri: uri) } + let(:ng_rule) { Fabricate(:ng_rule, account_field_name: 'Name') } + + it_behaves_like 'matches rule', 'account' + end + + context 'with field value rule' do + let(:account) { Fabricate(:account, fields_attributes: { '0' => { name: 'Name', value: 'Value' } }, domain: 'example.com', uri: uri) } + let(:ng_rule) { Fabricate(:ng_rule, account_field_value: 'Value') } + + it_behaves_like 'matches rule', 'account' + end + + context 'with avatar rule' do + context 'when avatar is not set' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: uri) } + let(:ng_rule) { Fabricate(:ng_rule, account_avatar_state: state) } + + it_behaves_like 'check all states', 'account', [false, true, false] + end + + context 'when avatar is set' do + let(:account) { Fabricate(:account, avatar: fixture_file_upload('avatar.gif', 'image/gif'), domain: 'example.com', uri: uri) } + let(:ng_rule) { Fabricate(:ng_rule, account_avatar_state: state) } + + it_behaves_like 'check all states', 'account', [false, false, true] + end + end + end + + describe '#check_status_or_record!' do + subject do + opts = { reaction_type: 'create' }.merge(options) + described_class.new(ng_rule, account, **opts).check_status_or_record! + end + + context 'when status matches but account does not match' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, text: 'this is a spam' } } + let(:ng_rule) { Fabricate(:ng_rule, account_domain: 'ohagi.jp', status_text: 'spam') } + + it_behaves_like 'does not match rule' + end + + context 'when account matches but status does not match' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, text: 'this is a spam' } } + let(:ng_rule) { Fabricate(:ng_rule, account_domain: 'example.com', status_text: 'span') } + + it_behaves_like 'does not match rule' + end + + context 'with text rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, text: 'this is a spam' } } + let(:ng_rule) { Fabricate(:ng_rule, status_text: 'spam') } + + it_behaves_like 'matches rule', 'status' + + it 'records as public' do + subject + + history = NgRuleHistory.order(id: :desc).find_by(ng_rule: ng_rule) + expect(history.hidden).to be false + end + end + + context 'with visibility rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:ng_rule) { Fabricate(:ng_rule, status_visibility: ['public', 'public_unlisted']) } + + context 'with public visibility' do + let(:options) { { uri: uri, visibility: 'public' } } + + it_behaves_like 'matches rule', 'status' + end + + context 'with unlisted visibility' do + let(:options) { { uri: uri, visibility: 'unlisted' } } + + it_behaves_like 'does not match rule', 'status' + end + end + + context 'with searchability rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:ng_rule) { Fabricate(:ng_rule, status_searchability: ['public', 'public_unlisted']) } + + context 'with public searchability' do + let(:options) { { uri: uri, searchability: 'public' } } + + it_behaves_like 'matches rule', 'status' + end + + context 'with private searchability' do + let(:options) { { uri: uri, searchability: 'private' } } + + it_behaves_like 'does not match rule', 'status' + end + + context 'with unset' do + let(:options) { { uri: uri, searchability: nil } } + + it_behaves_like 'does not match rule', 'status' + end + end + + context 'with reply rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, reply: false } } + let(:ng_rule) { Fabricate(:ng_rule, status_reply_state: :no_needed) } + + it_behaves_like 'matches rule', 'status' + end + + context 'with media size rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, media_count: 5 } } + let(:ng_rule) { Fabricate(:ng_rule, status_media_threshold: 4) } + + it_behaves_like 'matches rule', 'status' + end + + context 'with mention size rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, mention_count: 5 } } + let(:ng_rule) { Fabricate(:ng_rule, status_mention_threshold: 4, status_allow_follower_mention: false) } + + it_behaves_like 'matches rule', 'status' + + context 'when mention to stranger' do + let(:options) { { uri: uri, mention_count: 5, mention_to_following: false } } + let(:ng_rule) { Fabricate(:ng_rule, status_mention_threshold: 4, status_allow_follower_mention: true) } + + it_behaves_like 'matches rule', 'status' + end + + context 'when mention to follower' do + let(:options) { { uri: uri, mention_count: 5, mention_to_following: true } } + let(:ng_rule) { Fabricate(:ng_rule, status_mention_threshold: 4, status_allow_follower_mention: true) } + + it_behaves_like 'does not match rule', 'status' + end + end + + context 'with private privacy' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, text: 'this is a spam', visibility: 'private' } } + let(:ng_rule) { Fabricate(:ng_rule, status_text: 'spam', status_visibility: %w(private)) } + + it 'records as hidden' do + expect(subject).to be false + + history = NgRuleHistory.order(id: :desc).find_by(ng_rule: ng_rule) + expect(history).to_not be_nil + expect(history.account_id).to eq account.id + expect(history.reason).to eq 'status' + expect(history.uri).to be_nil + expect(history.hidden).to be true + expect(history.text).to be_nil + end + end + end + + describe '#check_reaction_or_record!' do + subject do + described_class.new(ng_rule, account, **options).check_reaction_or_record! + end + + context 'when account matches but reaction does not match' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, recipient: Fabricate(:account), reaction_type: 'favourite' } } + let(:ng_rule) { Fabricate(:ng_rule, account_domain: 'example.com', status_text: 'span', reaction_type: ['reblog']) } + + it_behaves_like 'does not match rule' + end + + context 'with reaction type rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, recipient: Fabricate(:account), reaction_type: 'favourite' } } + let(:ng_rule) { Fabricate(:ng_rule, reaction_type: ['favourite', 'follow']) } + + it_behaves_like 'matches rule', 'reaction' + + context 'when reblog' do + let(:options) { { uri: uri, recipient: Fabricate(:account), reaction_type: 'reblog' } } + + it_behaves_like 'does not match rule' + end + end + + context 'with emoji reaction shortcode rule' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') } + let(:options) { { uri: uri, recipient: Fabricate(:account), reaction_type: 'emoji_reaction', emoji_reaction_name: 'ohagi' } } + let(:ng_rule) { Fabricate(:ng_rule, reaction_type: ['emoji_reaction'], emoji_reaction_name: 'ohagi') } + + it_behaves_like 'matches rule', 'reaction' + end + end +end diff --git a/spec/models/ng_rule_spec.rb b/spec/models/ng_rule_spec.rb new file mode 100644 index 00000000000000..4947d261709c5a --- /dev/null +++ b/spec/models/ng_rule_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe NgRule do + describe '#copy!' do + let(:original) { Fabricate(:ng_rule, account_domain: 'foo.bar', account_avatar_state: :needed, status_text: 'ohagi', status_mention_threshold: 5, status_allow_follower_mention: false) } + let(:copied) { original.copy! } + + it 'saves safely' do + expect { copied.save! }.to_not raise_error + expect(copied.reload.id).to_not eq original.id + end + + it 'saves specified rules' do + expect(copied.account_domain).to eq 'foo.bar' + expect(copied.account_avatar_state.to_sym).to eq :needed + expect(copied.status_text).to eq 'ohagi' + expect(copied.status_mention_threshold).to eq 5 + expect(copied.status_allow_follower_mention).to be false + end + + it 'saves default rules' do + expect(copied.account_header_state.to_sym).to eq :optional + expect(copied.status_spoiler_text).to eq '' + expect(copied.status_reference_threshold).to eq(-1) + end + end +end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index cfe3e3eb97a746..232dff2962873b 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -681,5 +681,31 @@ def poll_option_json(name, votes) end end end + + context 'when ng rule is existing' do + context 'when ng rule is match' do + before do + Fabricate(:ng_rule, account_domain: 'example.com', status_text: 'universe') + subject.call(status, json, json) + end + + it 'does not update text' do + expect(status.reload.text).to eq 'Hello world' + expect(status.edits.reload.map(&:text)).to eq [] + end + end + + context 'when ng rule is not match' do + before do + Fabricate(:ng_rule, account_domain: 'foo.bar', status_text: 'universe') + subject.call(status, json, json) + end + + it 'updates text' do + expect(status.reload.text).to eq 'Hello universe' + expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe'] + end + end + end end end diff --git a/spec/services/delete_account_service_spec.rb b/spec/services/delete_account_service_spec.rb index a5ad55162eb87a..7e0ac4fcdd419c 100644 --- a/spec/services/delete_account_service_spec.rb +++ b/spec/services/delete_account_service_spec.rb @@ -47,6 +47,8 @@ let!(:account_note) { Fabricate(:account_note, account: account) } + let!(:ng_rule_history) { Fabricate(:ng_rule_history, account: account) } + it 'deletes associated owned and target records and target notifications' do subject @@ -68,6 +70,7 @@ expect { bookmark_category_status.status.reload }.to_not raise_error expect { antenna_account.account.reload }.to_not raise_error expect { circle_account.account.reload }.to_not raise_error + expect { ng_rule_history.reload }.to_not raise_error expect { list.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { list_account.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { antenna_account.reload }.to raise_error(ActiveRecord::RecordNotFound) diff --git a/spec/services/emoji_react_service_spec.rb b/spec/services/emoji_react_service_spec.rb index 8e651465fcbc72..cf0f6f595768ee 100644 --- a/spec/services/emoji_react_service_spec.rb +++ b/spec/services/emoji_react_service_spec.rb @@ -113,6 +113,33 @@ end end + context 'with ng rule' do + let(:name) { 'ohagi' } + + context 'when rule hits' do + before do + Fabricate(:custom_emoji, shortcode: 'ohagi') + Fabricate(:ng_rule, reaction_type: ['emoji_reaction']) + end + + it 'react with emoji' do + expect { subject }.to raise_error Mastodon::ValidationError + end + end + + context 'when rule does not hit' do + before do + Fabricate(:custom_emoji, shortcode: 'ohagi') + Fabricate(:ng_rule, reaction_type: ['emoji_reaction'], emoji_reaction_name: 'aaa') + end + + it 'react with emoji' do + expect { subject }.to_not raise_error + expect(subject.count).to eq 1 + end + end + end + context 'with custom emoji of remote' do let(:name) { 'ohagi@foo.bar' } let!(:custom_emoji) { Fabricate(:custom_emoji, shortcode: 'ohagi', domain: 'foo.bar', uri: 'https://foo.bar/emoji/ohagi') } diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index 3143e7b669ad09..c93dc6829a68cc 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -37,4 +37,31 @@ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end end + + context 'with ng rule' do + let(:status) { Fabricate(:status) } + let(:sender) { Fabricate(:account) } + + context 'when rule matches' do + before do + Fabricate(:ng_rule, reaction_type: ['favourite']) + end + + it 'does not favourite' do + expect { subject.call(sender, status) }.to raise_error Mastodon::ValidationError + expect(sender.favourited?(status)).to be false + end + end + + context 'when rule does not match' do + before do + Fabricate(:ng_rule, account_display_name: 'else', reaction_type: ['favourite']) + end + + it 'favourites' do + expect { subject.call(sender, status) }.to_not raise_error + expect(sender.favourited?(status)).to be true + end + end + end end diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index cf4de34c89d52f..b9e0ab625a749d 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -154,4 +154,30 @@ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end end + + context 'with ng rule' do + let(:bob) { Fabricate(:account) } + + context 'when rule matches' do + before do + Fabricate(:ng_rule, reaction_type: ['follow']) + end + + it 'does not favourite' do + expect { subject.call(sender, bob) }.to raise_error Mastodon::ValidationError + expect(sender.following?(bob)).to be false + end + end + + context 'when rule does not match' do + before do + Fabricate(:ng_rule, account_display_name: 'else', reaction_type: ['follow']) + end + + it 'favourites' do + expect { subject.call(sender, bob) }.to_not raise_error + expect(sender.following?(bob)).to be true + end + end + end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index d39d52ab9aa994..659cecb4e0b28b 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -820,6 +820,27 @@ end end + describe 'ng rule is set' do + it 'creates a new status when no rule matches' do + Fabricate(:ng_rule, account_username: 'ohagi', status_allow_follower_mention: false) + account = Fabricate(:account) + text = 'test status update' + + status = subject.call(account, text: text) + + expect(status).to be_persisted + expect(status.text).to eq text + end + + it 'does not create a new status when a rule matches' do + Fabricate(:ng_rule, status_text: 'test', status_allow_follower_mention: false) + account = Fabricate(:account) + text = 'test status update' + + expect { subject.call(account, text: text) }.to raise_error Mastodon::ValidationError + end + end + def create_status_with_options(**options) subject.call(Fabricate(:account), options.merge(text: 'test')) end diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index e17a3c59994da3..183e0ffe243a66 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -68,6 +68,35 @@ end end + context 'with ng rule' do + subject { described_class.new } + + let(:status) { Fabricate(:status, account: alice, visibility: :public) } + let(:account) { Fabricate(:account) } + + context 'when rule matches' do + before do + Fabricate(:ng_rule, reaction_type: ['reblog']) + end + + it 'does not reblog' do + expect { subject.call(account, status) }.to raise_error Mastodon::ValidationError + expect(account.reblogged?(status)).to be false + end + end + + context 'when rule does not match' do + before do + Fabricate(:ng_rule, account_display_name: 'else', reaction_type: ['reblog']) + end + + it 'reblogs' do + expect { subject.call(account, status) }.to_not raise_error + expect(account.reblogged?(status)).to be true + end + end + end + context 'when the reblogged status is discarded in the meantime' do let(:status) { Fabricate(:status, account: alice, visibility: :public, text: 'discard-status-text') } diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 38938552521527..89184a4f4c5659 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -401,4 +401,32 @@ def match_update_request(req, type) expect(status.text).to_not eq text end end + + describe 'ng rule is set' do + let(:status) { Fabricate(:status, text: 'Foo') } + + context 'when rule hits' do + before do + Fabricate(:ng_rule, status_text: 'Bar', status_allow_follower_mention: false) + end + + it 'does not update text' do + expect { subject.call(status, status.account_id, text: 'Bar') }.to raise_error Mastodon::ValidationError + expect(status.reload.text).to_not eq 'Bar' + expect(status.edits.pluck(:text)).to eq %w() + end + end + + context 'when rule does not hit' do + before do + Fabricate(:ng_rule, status_text: 'aar', status_allow_follower_mention: false) + end + + it 'does not update text' do + expect { subject.call(status, status.account_id, text: 'Bar') }.to_not raise_error + expect(status.reload.text).to eq 'Bar' + expect(status.edits.pluck(:text)).to eq %w(Foo Bar) + end + end + end end