Add: #600 NGルール (#602)

* 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

* 表示まわりの改善
This commit is contained in:
KMY(雪あすか) 2024-02-26 17:45:41 +09:00 committed by GitHub
parent 0779c748a6
commit 7d96d5828e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2062 additions and 42 deletions

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
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)

View file

@ -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 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
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 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
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 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
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 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
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

View file

@ -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 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
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)

View file

@ -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 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
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 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
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

View file

@ -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

205
app/models/admin/ng_rule.rb Normal file
View file

@ -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

114
app/models/ng_rule.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -504,6 +504,10 @@ class Status < ApplicationRecord
%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 @@ class Status < ApplicationRecord
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

View file

@ -4,6 +4,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
include JsonLdHelper
include Redisable
include Lockable
include NgRuleHelper
class AbortError < ::StandardError; end
@ -168,17 +169,38 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
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}")
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 mention_to_stranger?
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

View file

@ -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 @@ class EmojiReactService < BaseService
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)

View file

@ -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?

View file

@ -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 @@ class FollowService < BaseService
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)

View file

@ -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 @@ class PostStatusService < BaseService
@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 @@ class PostStatusService < BaseService
@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 @@ class PostStatusService < BaseService
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 @@ class PostStatusService < BaseService
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 = []

View file

@ -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 @@ class ProcessReferencesService < BaseService
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)

View file

@ -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 @@ class ReblogService < BaseService
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?

View file

@ -3,6 +3,7 @@
class UpdateStatusService < BaseService
include Redisable
include LanguagesHelper
include NgRuleHelper
class NoChangesSubmittedError < StandardError; end
@ -31,6 +32,8 @@ class UpdateStatusService < BaseService
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 @@ class UpdateStatusService < BaseService
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 @@ class UpdateStatusService < BaseService
end
def referred_statuses
return [] unless @options[:text]
return [] unless text
ProcessReferencesService.extract_uris(@options[:text]).filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) }
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
def text
@options.key?(:text) ? (@options[:text] || '') : @status.text
end
def validate_media!

View file

@ -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 @@ class VoteService < BaseService
@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

View file

@ -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'

View file

@ -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

View file

@ -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') }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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')
.fields-group
= f.input :hide_local_users_for_anonymous, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hide_local_users_for_anonymous')
.fields-group
= f.input :block_unfollow_account_mention, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.block_unfollow_account_mention')
%p.hint
= t 'admin.ng_words.remote_approval_hint'
%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 :hold_remote_new_accounts, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hold_remote_new_accounts')
= 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 :permit_new_account_domains, wrapper: :with_label, as: :text, kmyblue: true, input_html: { rows: 6 }, label: t('admin.ng_words.permit_new_account_domains')
%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 :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 :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

View file

@ -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
.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')
%p.lead= t 'admin.sensitive_words.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_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: 8 }, label: t('admin.sensitive_words.keywords'), hint: t('admin.sensitive_words.keywords_hint')
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View file

@ -26,6 +26,7 @@ class Scheduler::VacuumScheduler
feeds_vacuum,
imports_vacuum,
list_statuses_vacuum,
ng_histories_vacuum,
]
end
@ -65,6 +66,10 @@ class Scheduler::VacuumScheduler
Vacuum::ApplicationsVacuum.new
end
def ng_histories_vacuum
Vacuum::NgHistoriesVacuum.new
end
def content_retention_policy
ContentRetentionPolicy.current
end

View file

@ -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: <strong>Where multiple lines can be entered</strong>, 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: <strong>In specifying the upper limit</strong>, 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

View file

@ -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: <strong>複数行入力可能な箇所</strong>では、改行区切りで条件を入力します。複数行のうちどれか1つが含まれている場合、条件にマッチしたと判断されます。行頭を「?」で始めると正規表現を利用できます。
threshold_html: <strong>上限の指定</strong>では、「-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: フォロワー限定

View file

@ -50,6 +50,7 @@ SimpleNavigation::Configuration.run do |navigation|
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) }

View file

@ -34,6 +34,12 @@ namespace :admin do
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]

View file

@ -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

View file

@ -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 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_17_230006) do
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 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_17_230006) do
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -111,6 +111,38 @@ RSpec.describe ActivityPub::Activity::Announce do
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) }

View file

@ -1560,6 +1560,32 @@ RSpec.describe ActivityPub::Activity::Create do
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 @@ RSpec.describe ActivityPub::Activity::Create do
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 }

View file

@ -244,6 +244,33 @@ RSpec.describe ActivityPub::Activity::Follow do
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

View file

@ -55,6 +55,32 @@ RSpec.describe ActivityPub::Activity::Like do
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 @@ RSpec.describe ActivityPub::Activity::Like do
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -681,5 +681,31 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
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

View file

@ -47,6 +47,8 @@ RSpec.describe DeleteAccountService, type: :service do
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 @@ RSpec.describe DeleteAccountService, type: :service do
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)

View file

@ -113,6 +113,33 @@ RSpec.describe EmojiReactService, type: :service do
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') }

View file

@ -37,4 +37,31 @@ RSpec.describe FavouriteService, type: :service do
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

View file

@ -154,4 +154,30 @@ RSpec.describe FollowService, type: :service do
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

View file

@ -820,6 +820,27 @@ RSpec.describe PostStatusService, type: :service do
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

View file

@ -68,6 +68,35 @@ RSpec.describe ReblogService, type: :service do
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') }

View file

@ -401,4 +401,32 @@ RSpec.describe UpdateStatusService, type: :service do
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