* 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:
parent
0779c748a6
commit
7d96d5828e
56 changed files with 2062 additions and 42 deletions
|
@ -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'
|
||||
|
|
24
app/controllers/admin/ng_rule_histories_controller.rb
Normal file
24
app/controllers/admin/ng_rule_histories_controller.rb
Normal 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
|
115
app/controllers/admin/ng_rules_controller.rb
Normal file
115
app/controllers/admin/ng_rules_controller.rb
Normal 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
|
28
app/helpers/ng_rule_helper.rb
Normal file
28
app/helpers/ng_rule_helper.rb
Normal 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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
17
app/lib/vacuum/ng_histories_vacuum.rb
Normal file
17
app/lib/vacuum/ng_histories_vacuum.rb
Normal 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
205
app/models/admin/ng_rule.rb
Normal 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
114
app/models/ng_rule.rb
Normal 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
|
35
app/models/ng_rule_history.rb
Normal file
35
app/models/ng_rule_history.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
42
app/views/admin/ng_rule_histories/_history.html.haml
Normal file
42
app/views/admin/ng_rule_histories/_history.html.haml
Normal 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'
|
25
app/views/admin/ng_rule_histories/show.html.haml
Normal file
25
app/views/admin/ng_rule_histories/show.html.haml
Normal 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
|
25
app/views/admin/ng_rules/_ng_rule.html.haml
Normal file
25
app/views/admin/ng_rules/_ng_rule.html.haml
Normal 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') }
|
123
app/views/admin/ng_rules/_ng_rule_fields.html.haml
Normal file
123
app/views/admin/ng_rules/_ng_rule_fields.html.haml
Normal 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
|
9
app/views/admin/ng_rules/edit.html.haml
Normal file
9
app/views/admin/ng_rules/edit.html.haml
Normal 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
|
14
app/views/admin/ng_rules/index.html.haml
Normal file
14
app/views/admin/ng_rules/index.html.haml
Normal 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
|
8
app/views/admin/ng_rules/new.html.haml
Normal file
8
app/views/admin/ng_rules/new.html.haml
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: フォロワー限定
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
64
db/migrate/20240218233621_create_ng_rules.rb
Normal file
64
db/migrate/20240218233621_create_ng_rules.rb
Normal 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
|
63
db/schema.rb
63
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_02_17_230006) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_02_18_233621) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
@ -870,6 +870,65 @@ 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
|
||||
|
|
|
@ -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
|
||||
|
|
7
spec/fabricators/ng_rule_fabricator.rb
Normal file
7
spec/fabricators/ng_rule_fabricator.rb
Normal 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
|
8
spec/fabricators/ng_rule_history_fabricator.rb
Normal file
8
spec/fabricators/ng_rule_history_fabricator.rb
Normal 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
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
24
spec/lib/vacuum/ng_histories_vacuum_spec.rb
Normal file
24
spec/lib/vacuum/ng_histories_vacuum_spec.rb
Normal 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
|
282
spec/models/admin/ng_rule_spec.rb
Normal file
282
spec/models/admin/ng_rule_spec.rb
Normal 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
|
29
spec/models/ng_rule_spec.rb
Normal file
29
spec/models/ng_rule_spec.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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') }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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') }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue