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

* Wip

* Wip

* Wip: History

* Wip: テストコード作成

* Fix test

* Wip

* Wip

* Wip

* Fix test

* Wip

* Wip

* Wip

* Wip

* なんとか完成、これから動作確認

* spell miss

* Change ng rule timings

* Fix test

* Wip

* Fix test

* Wip

* Fix form

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

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Admin
class NgRuleHistoriesController < BaseController
before_action :set_ng_rule
before_action :set_histories
PER_PAGE = 20
def show
authorize :ng_words, :show?
end
private
def set_ng_rule
@ng_rule = ::NgRule.find(params[:id])
end
def set_histories
@histories = NgRuleHistory.where(ng_rule_id: params[:id]).order(id: :desc).page(params[:page]).per(PER_PAGE)
end
end
end

View file

@ -0,0 +1,115 @@
# frozen_string_literal: true
module Admin
class NgRulesController < BaseController
before_action :set_ng_rule, only: [:edit, :update, :destroy, :duplicate]
def index
authorize :ng_words, :show?
@ng_rules = ::NgRule.order(id: :asc)
end
def new
authorize :ng_words, :show?
@ng_rule = ::NgRule.build
end
def edit
authorize :ng_words, :show?
end
def create
authorize :ng_words, :create?
begin
test_words!
rescue
flash[:alert] = I18n.t('admin.ng_rules.test_error')
redirect_to new_admin_ng_rule_path
return
end
@ng_rule = ::NgRule.build(resource_params)
if @ng_rule.save
redirect_to admin_ng_rules_path
else
render :new
end
end
def update
authorize :ng_words, :create?
begin
test_words!
rescue
flash[:alert] = I18n.t('admin.ng_rules.test_error')
redirect_to edit_admin_ng_rule_path(id: @ng_rule.id)
return
end
if @ng_rule.update(resource_params)
redirect_to admin_ng_rules_path
else
render :edit
end
end
def duplicate
authorize :ng_words, :create?
@ng_rule = @ng_rule.copy!
flash[:alert] = I18n.t('admin.ng_rules.copy_error') unless @ng_rule.save
redirect_to admin_ng_rules_path
end
def destroy
authorize :ng_words, :create?
@ng_rule.destroy
redirect_to admin_ng_rules_path
end
private
def set_ng_rule
@ng_rule = ::NgRule.find(params[:id])
end
def resource_params
params.require(:ng_rule).permit(:title, :expires_in, :available, :account_domain, :account_username, :account_display_name,
:account_note, :account_field_name, :account_field_value, :account_avatar_state,
:account_header_state, :account_include_local, :status_spoiler_text, :status_text, :status_tag,
:status_sensitive_state, :status_cw_state, :status_media_state, :status_poll_state,
:status_mention_state, :status_reference_state,
:status_quote_state, :status_reply_state, :status_media_threshold, :status_poll_threshold,
:status_mention_threshold, :status_allow_follower_mention,
:reaction_allow_follower, :emoji_reaction_name, :emoji_reaction_origin_domain,
:status_reference_threshold, :account_allow_followed_by_local, :record_history_also_local,
status_visibility: [], status_searchability: [], reaction_type: [])
end
def test_words!
arr = [
resource_params[:account_domain],
resource_params[:account_username],
resource_params[:account_display_name],
resource_params[:account_note],
resource_params[:account_field_name],
resource_params[:account_field_value],
resource_params[:status_spoiler_text],
resource_params[:status_text],
resource_params[:status_tag],
resource_params[:emoji_reaction_name],
resource_params[:emoji_reaction_origin_domain],
].compact_blank.join("\n")
Admin::NgRule.extract_test!(arr) if arr.present?
end
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
module NgRuleHelper
def check_invalid_status_for_ng_rule!(account, **options)
(check_for_ng_rule!(account, **options) { |rule| !rule.check_status_or_record! }).none?
end
def check_invalid_reaction_for_ng_rule!(account, **options)
(check_for_ng_rule!(account, **options) { |rule| !rule.check_reaction_or_record! }).none?
end
private
def check_for_ng_rule!(account, **options, &block)
NgRule.cached_rules
.map { |raw_rule| Admin::NgRule.new(raw_rule, account, **options) }
.filter(&block)
end
def do_account_action_for_rule!(account, action)
case action
when :silence
account.silence!
when :suspend
account.suspend!
end
end
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class ActivityPub::Activity::Announce < ActivityPub::Activity
include NgRuleHelper
def perform
return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
@ -9,6 +11,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
return reject_payload! if original_status.nil? || !announceable?(original_status)
return if requested_through_relay?
return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'reblog', recipient: original_status.account, target_status: original_status
@status = Status.find_by(account: @account, reblog: original_status)

View file

@ -2,6 +2,7 @@
class ActivityPub::Activity::Create < ActivityPub::Activity
include FormattingHelper
include NgRuleHelper
def perform
@account.schedule_refresh_if_stale!
@ -144,7 +145,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def valid_status?
valid = !Admin::NgWord.reject?("#{@params[:spoiler_text]}\n#{@params[:text]}", uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?)
valid = true
valid = false if valid && !valid_status_for_ng_rule?
valid = !Admin::NgWord.reject?("#{@params[:spoiler_text]}\n#{@params[:text]}", uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?) if valid
valid = !Admin::NgWord.hashtag_reject?(@tags.size, uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?, text: "#{@params[:spoiler_text]}\n#{@params[:text]}") if valid
valid = !Admin::NgWord.mention_reject?(@raw_mention_uris.size, uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?, text: "#{@params[:spoiler_text]}\n#{@params[:text]}") if valid
valid = !Admin::NgWord.stranger_mention_reject_with_count?(@raw_mention_uris.size, uri: @params[:uri], target_type: :status, public: @status_parser.distributable_visibility?, text: "#{@params[:spoiler_text]}\n#{@params[:text]}") if valid && (mention_to_local_stranger? || reference_to_local_stranger?)
@ -154,6 +157,26 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
valid
end
def valid_status_for_ng_rule?
check_invalid_status_for_ng_rule! @account,
reaction_type: 'create',
uri: @params[:uri],
url: @params[:url],
spoiler_text: @params[:spoiler_text],
text: @params[:text],
tag_names: @tags.map(&:name),
visibility: @params[:visibility].to_s,
searchability: @params[:searchability]&.to_s,
sensitive: @params[:sensitive],
media_count: @params[:media_attachment_ids]&.size,
poll_count: @params[:poll]&.options&.size || 0,
quote: quote,
reply: in_reply_to_uri.present?,
mention_count: mentioned_accounts.count,
reference_count: reference_uris.size,
mention_to_following: !(mention_to_local_stranger? || reference_to_local_stranger?)
end
def accounts_in_audience
return @accounts_in_audience if @accounts_in_audience
@ -353,6 +376,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def poll_vote?
return false if replied_to_status.nil? || replied_to_status.preloadable_poll.nil? || !replied_to_status.local? || !replied_to_status.preloadable_poll.options.include?(@object['name'])
return true unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'vote', recipient: replied_to_status.account, target_status: replied_to_status
poll_vote! unless replied_to_status.preloadable_poll.expired?
true
@ -552,7 +577,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return @reference_uris if defined?(@reference_uris)
@reference_uris = @object['references'].nil? ? [] : (ActivityPub::FetchReferencesService.new.call(@account, @object['references']) || []).uniq
@reference_uris += ProcessReferencesService.extract_uris(@object['content'] || '')
@reference_uris += ProcessReferencesService.extract_uris(@object['content'] || '', remote: true)
end
def local_referred_accounts

View file

@ -3,6 +3,7 @@
class ActivityPub::Activity::Follow < ActivityPub::Activity
include Payloadable
include FollowHelper
include NgRuleHelper
def perform
return request_follow_for_friend if friend_follow?
@ -10,6 +11,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
target_account = account_from_uri(object_uri)
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id'])
return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'follow', recipient: target_account
# Update id of already-existing follow requests
existing_follow_request = ::FollowRequest.find_by(account: @account, target_account: target_account) || PendingFollowRequest.find_by(account: @account, target_account: target_account)

View file

@ -4,6 +4,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
include Redisable
include Lockable
include JsonLdHelper
include NgRuleHelper
def perform
@original_status = status_from_uri(object_uri)
@ -25,6 +26,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
def process_favourite
return if @account.favourited?(@original_status)
return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'favourite', recipient: @original_status.account, target_status: @original_status
favourite = @original_status.favourites.create!(account: @account, uri: @json['id'])
@ -43,6 +45,8 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
return if emoji.nil?
end
return unless check_invalid_reaction_for_ng_rule! @account, uri: @json['id'], reaction_type: 'emoji_reaction', emoji_reaction_name: emoji&.shortcode || shortcode, emoji_reaction_origin_domain: emoji&.domain, recipient: @original_status.account, target_status: @original_status
reaction = nil
with_redis_lock("emoji_reaction:#{@original_status.id}") do

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Vacuum::NgHistoriesVacuum
include Redisable
HISTORY_LIFE_DURATION = 7.days.freeze
def perform
vacuum_histories!
end
private
def vacuum_histories!
NgRuleHistory.where('created_at < ?', HISTORY_LIFE_DURATION.ago).in_batches.destroy_all
end
end

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

@ -0,0 +1,205 @@
# frozen_string_literal: true
class Admin::NgRule
def initialize(ng_rule, account, **options)
@ng_rule = ng_rule
@account = account
@options = options
@uri = nil
end
def account_match?
return false if @account.local? && !@ng_rule.account_include_local
return false if !@account.local? && @ng_rule.account_allow_followed_by_local && followed_by_local_accounts?
if @account.local?
return false unless @ng_rule.account_include_local
else
return false unless text_match?(:account_domain, @account.domain, @ng_rule.account_domain)
end
text_match?(:account_username, @account.username, @ng_rule.account_username) &&
text_match?(:account_display_name, @account.display_name, @ng_rule.account_display_name) &&
text_match?(:account_note, @account.note, @ng_rule.account_note) &&
text_match?(:account_field_name, @account.fields&.map(&:name)&.join("\n"), @ng_rule.account_field_name) &&
text_match?(:account_field_value, @account.fields&.map(&:value)&.join("\n"), @ng_rule.account_field_value) &&
media_state_match?(:account_avatar_state, @account.avatar, @ng_rule.account_avatar_state) &&
media_state_match?(:account_header_state, @account.header, @ng_rule.account_header_state)
end
def status_match? # rubocop:disable Metrics/CyclomaticComplexity
return false if @ng_rule.status_allow_follower_mention && @options[:mention_to_following]
has_media = @options[:media_count].is_a?(Integer) && @options[:media_count].positive?
has_poll = @options[:poll_count].is_a?(Integer) && @options[:poll_count].positive?
has_mention = @options[:mention_count].is_a?(Integer) && @options[:mention_count].positive?
has_reference = @options[:reference_count].is_a?(Integer) && @options[:reference_count].positive?
@options = @options.merge({ searchability: 'unset' }) if @options[:searchability].nil?
text_match?(:status_spoiler_text, @options[:spoiler_text], @ng_rule.status_spoiler_text) &&
text_match?(:status_text, @options[:text], @ng_rule.status_text) &&
text_match?(:status_tag, @options[:tag_names]&.join("\n"), @ng_rule.status_tag) &&
enum_match?(:status_visibility, @options[:visibility], @ng_rule.status_visibility) &&
enum_match?(:status_searchability, @options[:searchability], @ng_rule.status_searchability) &&
state_match?(:status_sensitive_state, @options[:sensitive], @ng_rule.status_sensitive_state) &&
state_match?(:status_cw_state, @options[:spoiler_text].present?, @ng_rule.status_cw_state) &&
state_match?(:status_media_state, has_media, @ng_rule.status_media_state) &&
state_match?(:status_poll_state, has_poll, @ng_rule.status_poll_state) &&
state_match?(:status_quote_state, @options[:quote], @ng_rule.status_quote_state) &&
state_match?(:status_reply_state, @options[:reply], @ng_rule.status_reply_state) &&
state_match?(:status_mention_state, has_mention, @ng_rule.status_mention_state) &&
state_match?(:status_reference_state, has_reference, @ng_rule.status_reference_state) &&
value_over_threshold?(:status_tag_threshold, (@options[:tag_names] || []).size, @ng_rule.status_tag_threshold) &&
value_over_threshold?(:status_media_threshold, @options[:media_count], @ng_rule.status_media_threshold) &&
value_over_threshold?(:status_poll_threshold, @options[:poll_count], @ng_rule.status_poll_threshold) &&
value_over_threshold?(:status_mention_threshold, @options[:mention_count], @ng_rule.status_mention_threshold) &&
value_over_threshold?(:status_reference_threshold, @options[:reference_count], @ng_rule.status_reference_threshold)
end
def reaction_match?
recipient = @options[:recipient]
return false if @ng_rule.reaction_allow_follower && (recipient.id == @account.id || (!recipient.local? && !@account.local?) || recipient.following?(@account))
if @options[:reaction_type] == 'emoji_reaction'
enum_match?(:reaction_type, @options[:reaction_type], @ng_rule.reaction_type) &&
text_match?(:emoji_reaction_name, @options[:emoji_reaction_name], @ng_rule.emoji_reaction_name) &&
text_match?(:emoji_reaction_origin_domain, @options[:emoji_reaction_origin_domain], @ng_rule.emoji_reaction_origin_domain)
else
enum_match?(:reaction_type, @options[:reaction_type], @ng_rule.reaction_type)
end
end
def check_account_or_record!
return true unless account_match?
record!('account', @account.uri, 'account_create') if !@account.local? || @ng_rule.record_history_also_local
false
end
def check_status_or_record!
return true unless account_match? && status_match?
text = [@options[:spoiler_text], @options[:text]].compact_blank.join("\n\n")
data = {
media_count: @options[:media_count],
poll_count: @options[:poll_count],
url: @options[:url],
}
record!('status', @options[:uri], "status_#{@options[:reaction_type]}", text: text, data: data) if !@account.local? || @ng_rule.record_history_also_local
false
end
def check_reaction_or_record!
return true unless account_match? && reaction_match?
text = @options[:target_status].present? ? [@options[:target_status].spoiler_text, @options[:target_status].text].compact_blank.join("\n\n") : nil
data = {
url: @options[:target_status].present? ? @options[:target_status].url : nil,
}
record!('reaction', @options[:uri], "reaction_#{@options[:reaction_type]}", text: text, data: data) if !@account.local? || @ng_rule.record_history_also_local
false
end
def loggable_visibility?
visibility = @options[:target_status]&.visibility || @options[:visibility]
return true unless visibility
%i(public public_unlisted login unlisted).include?(visibility.to_sym)
end
def self.extract_test!(custom_ng_words)
detect_keyword?('test', custom_ng_words)
end
private
def followed_by_local_accounts?
Follow.exists?(account: Account.local, target_account: @account)
end
def record!(reason, uri, reason_action, **options)
opts = options.merge({
ng_rule: @ng_rule,
account: @account,
local: @account.local?,
reason: reason,
reason_action: reason_action,
uri: uri,
})
unless loggable_visibility?
opts = opts.merge({
text: nil,
uri: nil,
hidden: true,
})
end
NgRuleHistory.create!(**opts)
end
def text_match?(_reason, text, arr)
return true if arr.blank? || !text.is_a?(String)
detect_keyword?(text, arr)
end
def enum_match?(_reason, text, arr)
return true if !text.is_a?(String) || text.blank?
arr.include?(text)
end
def state_match?(_reason, exists, expected)
case expected.to_sym
when :needed
exists
when :no_needed
!exists
else
true
end
end
def media_state_match?(reason, media, expected)
state_match?(reason, media.present?, expected)
end
def value_over_threshold?(_reason, value, expected)
return true if !expected.is_a?(Integer) || expected.negative? || !value.is_a?(Integer)
value > expected
end
def detect_keyword?(text, arr)
Admin::NgRule.detect_keyword?(text, arr)
end
class << self
def string_to_array(text)
text.delete("\r").split("\n")
end
def detect_keyword(text, arr)
arr = string_to_array(arr) if arr.is_a?(String)
arr.detect { |word| include?(text, word) ? word : nil }
end
def detect_keyword?(text, arr)
detect_keyword(text, arr).present?
end
def include?(text, word)
if word.start_with?('?') && word.size >= 2
text =~ /#{word[1..]}/
else
text.include?(word)
end
end
end
end

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

@ -0,0 +1,114 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: ng_rules
#
# id :bigint(8) not null, primary key
# title :string default(""), not null
# available :boolean default(TRUE), not null
# record_history_also_local :boolean default(TRUE), not null
# account_domain :string default(""), not null
# account_username :string default(""), not null
# account_display_name :string default(""), not null
# account_note :string default(""), not null
# account_field_name :string default(""), not null
# account_field_value :string default(""), not null
# account_avatar_state :integer default("optional"), not null
# account_header_state :integer default("optional"), not null
# account_include_local :boolean default(TRUE), not null
# account_allow_followed_by_local :boolean default(FALSE), not null
# status_spoiler_text :string default(""), not null
# status_text :string default(""), not null
# status_tag :string default(""), not null
# status_visibility :string default([]), not null, is an Array
# status_searchability :string default([]), not null, is an Array
# status_media_state :integer default("optional"), not null
# status_sensitive_state :integer default("optional"), not null
# status_cw_state :integer default("optional"), not null
# status_poll_state :integer default("optional"), not null
# status_quote_state :integer default("optional"), not null
# status_reply_state :integer default("optional"), not null
# status_mention_state :integer default(0), not null
# status_reference_state :integer default(0), not null
# status_tag_threshold :integer default(-1), not null
# status_media_threshold :integer default(-1), not null
# status_poll_threshold :integer default(-1), not null
# status_mention_threshold :integer default(-1), not null
# status_allow_follower_mention :boolean default(TRUE), not null
# status_reference_threshold :integer default(-1), not null
# reaction_type :string default([]), not null, is an Array
# reaction_allow_follower :boolean default(TRUE), not null
# emoji_reaction_name :string default(""), not null
# emoji_reaction_origin_domain :string default(""), not null
# expires_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
class NgRule < ApplicationRecord
include Expireable
include Redisable
has_many :histories, class_name: 'NgRuleHistory', inverse_of: :ng_rule, dependent: :destroy
enum :account_avatar_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :account_avatar
enum :account_header_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :account_header
enum :status_sensitive_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_sensitive
enum :status_cw_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_cw
enum :status_quote_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_quote
enum :status_reply_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_reply
enum :status_media_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_media
enum :status_poll_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_poll
enum :status_mention_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_mention
enum :status_reference_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_reference
scope :enabled, -> { where(available: true) }
before_validation :clean_up_arrays
before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache!
def self.cached_rules
active_rules = Rails.cache.fetch('ng_rules') do
NgRule.enabled.to_a
end
active_rules.reject { |ng_rule, _| ng_rule.expired? }
end
def expires_in
return @expires_in if defined?(@expires_in)
return nil if expires_at.nil?
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week, 2.weeks, 1.month, 3.months].find { |expires_in| expires_in.from_now >= expires_at }
end
def copy!
dup
end
def hit_count
Rails.cache.fetch("ng_rule:hit_count:#{id}", expires_in: 15.minutes) { NgRuleHistory.where(ng_rule_id: id).count }
end
private
def clean_up_arrays
self.status_visibility = Array(status_visibility).map(&:strip).filter_map(&:presence)
self.status_searchability = Array(status_searchability).map(&:strip).filter_map(&:presence)
self.reaction_type = Array(reaction_type).map(&:strip).filter_map(&:presence)
end
def prepare_cache_invalidation!
@should_invalidate_cache = true
end
def invalidate_cache!
return unless @should_invalidate_cache
@should_invalidate_cache = false
Rails.cache.delete('ng_rules')
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: ng_rule_histories
#
# id :bigint(8) not null, primary key
# ng_rule_id :bigint(8) not null
# account_id :bigint(8)
# text :string
# uri :string
# reason :integer not null
# reason_action :integer not null
# local :boolean default(TRUE), not null
# hidden :boolean default(FALSE), not null
# data :jsonb
# created_at :datetime not null
# updated_at :datetime not null
#
class NgRuleHistory < ApplicationRecord
enum :reason, { account: 0, status: 1, reaction: 2 }, prefix: :reason
enum :reason_action, {
account_create: 0,
status_create: 10,
status_edit: 11,
reaction_favourite: 20,
reaction_emoji_reaction: 21,
reaction_follow: 22,
reaction_reblog: 23,
reaction_vote: 24,
}, prefix: :reason_action
belongs_to :ng_rule
belongs_to :account
end

View file

@ -504,6 +504,10 @@ class Status < ApplicationRecord
%w(unset) + selectable_visibilities
end
def all_visibilities
visibilities.keys
end
def selectable_searchabilities
ss = searchabilities.keys - %w(unsupported)
ss -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility
@ -514,6 +518,10 @@ class Status < ApplicationRecord
searchabilities.keys - %w(public_unlisted unsupported)
end
def all_searchabilities
searchabilities.keys - %w(unlisted login unsupported)
end
def favourites_map(status_ids, account_id)
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
end

View file

@ -4,6 +4,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
include JsonLdHelper
include Redisable
include Lockable
include NgRuleHelper
class AbortError < ::StandardError; end
@ -168,17 +169,38 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
def validate_status_mentions!
raise AbortError if (mention_to_stranger? || reference_to_stranger?) && Admin::NgWord.stranger_mention_reject?("#{@status_parser.spoiler_text}\n#{@status_parser.text}", uri: @status.uri, target_type: :status)
raise AbortError unless valid_status_for_ng_rule?
raise AbortError if (mention_to_local_stranger? || reference_to_local_stranger?) && Admin::NgWord.stranger_mention_reject?("#{@status_parser.spoiler_text}\n#{@status_parser.text}", uri: @status.uri, target_type: :status)
raise AbortError if Admin::NgWord.mention_reject?(@raw_mentions.size, uri: @status.uri, target_type: :status, text: "#{@status_parser.spoiler_text}\n#{@status_parser.text}")
raise AbortError if (mention_to_stranger? || reference_to_stranger?) && Admin::NgWord.stranger_mention_reject_with_count?(@raw_mentions.size, uri: @status.uri, target_type: :status, text: "#{@status_parser.spoiler_text}\n#{@status_parser.text}")
raise AbortError if (mention_to_local_stranger? || reference_to_local_stranger?) && Admin::NgWord.stranger_mention_reject_with_count?(@raw_mentions.size, uri: @status.uri, target_type: :status, text: "#{@status_parser.spoiler_text}\n#{@status_parser.text}")
end
def mention_to_stranger?
def valid_status_for_ng_rule?
check_invalid_status_for_ng_rule! @account,
reaction_type: 'edit',
uri: @status.uri,
url: @status_parser.url || @status.url,
spoiler_text: @status.spoiler_text,
text: @status.text,
tag_names: @raw_tags,
visibility: @status.visibility,
searchability: @status.searchability,
sensitive: @status.sensitive,
media_count: @next_media_attachments.size,
poll_count: @status.poll&.options&.size || 0,
quote: quote,
reply: @status.reply?,
mention_count: @status.mentions.count,
reference_count: reference_uris.size,
mention_to_following: !(mention_to_local_stranger? || reference_to_local_stranger?)
end
def mention_to_local_stranger?
@status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @status.account.id && mentioned_account.local? && !mentioned_account.following?(@status.account) } ||
(@status.thread.present? && @status.thread.account.id != @status.account.id && @status.thread.account.local? && !@status.thread.account.following?(@status.account))
end
def reference_to_stranger?
def reference_to_local_stranger?
local_referred_accounts.any? { |account| !account.following?(@account) }
end

View file

@ -5,6 +5,7 @@ class EmojiReactService < BaseService
include Payloadable
include Redisable
include Lockable
include NgRuleHelper
# React a status with emoji and notify remote user
# @param [Account] account
@ -18,9 +19,12 @@ class EmojiReactService < BaseService
raise Mastodon::ValidationError, I18n.t('reactions.errors.banned') if account.silenced? && !status.account.following?(account)
shortcode, domain = name.split('@')
domain = nil if TagManager.instance.local_domain?(domain)
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! account, reaction_type: 'emoji_reaction', emoji_reaction_name: shortcode, emoji_reaction_origin_domain: domain, recipient: status.account, target_status: status
with_redis_lock("emoji_reaction:#{status.id}") do
shortcode, domain = name.split('@')
domain = nil if TagManager.instance.local_domain?(domain)
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
return if domain.present? && !EmojiReaction.exists?(status: status, custom_emoji: custom_emoji)

View file

@ -3,6 +3,7 @@
class FavouriteService < BaseService
include Authorization
include Payloadable
include NgRuleHelper
# Favourite a status and notify remote user
# @param [Account] account
@ -11,6 +12,8 @@ class FavouriteService < BaseService
def call(account, status)
authorize_with account, status, :favourite?
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! account, reaction_type: 'favourite', recipient: status.account, target_status: status
favourite = Favourite.find_by(account: account, status: status)
return favourite unless favourite.nil?

View file

@ -4,6 +4,7 @@ class FollowService < BaseService
include Redisable
include Payloadable
include DomainControlHelper
include NgRuleHelper
# Follow a remote user, notify remote user about the follow
# @param [Account] source_account From which to follow
@ -23,6 +24,8 @@ class FollowService < BaseService
raise ActiveRecord::RecordNotFound if following_not_possible?
raise Mastodon::NotPermittedError if following_not_allowed?
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! @source_account, reaction_type: 'follow', recipient: @target_account
if @source_account.following?(@target_account)
return change_follow_options!
elsif @source_account.requested?(@target_account)

View file

@ -4,6 +4,7 @@ class PostStatusService < BaseService
include Redisable
include LanguagesHelper
include DtlHelper
include NgRuleHelper
MIN_SCHEDULE_OFFSET = 5.minutes.freeze
@ -74,7 +75,7 @@ class PostStatusService < BaseService
@visibility = :limited if %w(mutual circle reply).include?(@options[:visibility])
@visibility = :unlisted if (@visibility == :public || @visibility == :public_unlisted || @visibility == :login) && @account.silenced?
@visibility = :public_unlisted if @visibility == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility
@visibility = Setting.enable_public_unlisted_visibility ? :public_unlisted : :unlisted unless Setting.enable_public_visibility
@visibility = Setting.enable_public_unlisted_visibility ? :public_unlisted : :unlisted if !Setting.enable_public_visibility && @visibility == :public
@limited_scope = @options[:visibility]&.to_sym if @visibility == :limited && @options[:visibility] != 'limited'
@searchability = searchability
@searchability = :private if @account.silenced? && %i(public public_unlisted).include?(@searchability&.to_sym)
@ -148,6 +149,7 @@ class PostStatusService < BaseService
@status = @account.statuses.new(status_attributes)
process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? @limited_scope : '', circle: @circle, save_records: false)
safeguard_mentions!(@status)
validate_status_ng_rules!
validate_status_mentions!
@status.limited_scope = :personal if @status.limited_visibility? && !@status.reply_limited? && !process_mentions_service.mentions?
@ -218,9 +220,35 @@ class PostStatusService < BaseService
raise Mastodon::ValidationError, I18n.t('statuses.contains_ng_words') if (mention_to_stranger? || reference_to_stranger?) && Setting.stranger_mention_from_local_ng && Admin::NgWord.stranger_mention_reject?("#{@options[:spoiler_text]}\n#{@options[:text]}")
end
def validate_status_ng_rules!
result = check_invalid_status_for_ng_rule! @account,
reaction_type: 'create',
spoiler_text: @options[:spoiler_text] || '',
text: @text,
tag_names: Extractor.extract_hashtags(@text) || [],
visibility: @visibility.to_s,
searchability: @searchability.to_s,
sensitive: @sensitive,
media_count: (@media || []).size,
poll_count: @options.dig(:poll, 'options')&.size || 0,
quote: quote_url,
reply: @in_reply_to.present?,
mention_count: mention_count,
reference_count: reference_urls.size,
mention_to_following: !(mention_to_stranger? || reference_to_stranger?)
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless result
end
def mention_count
@text.gsub(Account::MENTION_RE)&.count || 0
end
def mention_to_stranger?
@status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @account.id && !mentioned_account.following?(@account) } ||
(@in_reply_to && @in_reply_to.account.id != @account.id && !@in_reply_to.account.following?(@account))
return @mention_to_stranger if defined?(@mention_to_stranger)
@mention_to_stranger = @status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @account.id && !mentioned_account.following?(@account) } ||
(@in_reply_to && @in_reply_to.account.id != @account.id && !@in_reply_to.account.following?(@account))
end
def reference_to_stranger?
@ -228,12 +256,20 @@ class PostStatusService < BaseService
end
def referred_statuses
statuses = ProcessReferencesService.extract_uris(@text).filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) }
statuses = reference_urls.filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) }
statuses += Status.where(id: @reference_ids) if @reference_ids.present?
statuses
end
def quote_url
ProcessReferencesService.extract_quote(@text)
end
def reference_urls
@reference_urls ||= ProcessReferencesService.extract_uris(@text) || []
end
def validate_media!
if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
@media = []

View file

@ -8,6 +8,7 @@ class ProcessReferencesService < BaseService
DOMAIN = ENV['WEB_DOMAIN'] || ENV.fetch('LOCAL_DOMAIN', nil)
REFURL_EXP = /(RT|QT|BT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/
QUOTEURL_EXP = /(QT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/
MAX_REFERENCES = 5
def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_urls: nil, quote_urls: nil)
@ -45,8 +46,14 @@ class ProcessReferencesService < BaseService
reference_parameters.any? || (urls || []).any? || (quote_urls || []).any? || FormattingHelper.extract_status_plain_text(status).scan(REFURL_EXP).pluck(3).uniq.any?
end
def self.extract_uris(text)
text.scan(REFURL_EXP).pluck(3)
def self.extract_uris(text, remote: false)
return text.scan(REFURL_EXP).pluck(3) unless remote
PlainTextFormatter.new(text, false).to_s.scan(REFURL_EXP).pluck(3)
end
def self.extract_quote(text)
text.scan(QUOTEURL_EXP).pick(3)
end
def self.perform_worker_async(status, reference_parameters, urls, quote_urls)

View file

@ -3,6 +3,7 @@
class ReblogService < BaseService
include Authorization
include Payloadable
include NgRuleHelper
# Reblog a status and notify its remote author
# @param [Account] account Account to reblog from
@ -16,6 +17,8 @@ class ReblogService < BaseService
authorize_with account, reblogged_status, :reblog?
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! account, reaction_type: 'reblog', recipient: reblogged_status.account, target_status: reblogged_status
reblog = account.statuses.find_by(reblog: reblogged_status)
return reblog unless reblog.nil?

View file

@ -3,6 +3,7 @@
class UpdateStatusService < BaseService
include Redisable
include LanguagesHelper
include NgRuleHelper
class NoChangesSubmittedError < StandardError; end
@ -31,6 +32,8 @@ class UpdateStatusService < BaseService
validate_status!
Status.transaction do
validate_status_ng_rules!
create_previous_edit! unless @options[:no_history]
update_media_attachments! if @options.key?(:media_ids)
update_poll! if @options.key?(:poll)
@ -91,6 +94,30 @@ class UpdateStatusService < BaseService
raise Mastodon::ValidationError, I18n.t('statuses.contains_ng_words') if (mention_to_stranger? || reference_to_stranger?) && Setting.stranger_mention_from_local_ng && Admin::NgWord.stranger_mention_reject?("#{@options[:spoiler_text]}\n#{@options[:text]}")
end
def validate_status_ng_rules!
result = check_invalid_status_for_ng_rule! @status.account,
reaction_type: 'edit',
spoiler_text: @options.key?(:spoiler_text) ? (@options[:spoiler_text] || '') : @status.spoiler_text,
text: text,
tag_names: Extractor.extract_hashtags(text) || [],
visibility: @status.visibility,
searchability: @status.searchability,
sensitive: @options.key?(:sensitive) ? @options[:sensitive] : @status.sensitive,
media_count: @options[:media_ids].present? ? @options[:media_ids].size : @status.media_attachments.count,
poll_count: @options.dig(:poll, 'options')&.size || 0,
quote: quote_url,
reply: @status.reply?,
mention_count: mention_count,
reference_count: reference_urls.size,
mention_to_following: !(mention_to_stranger? || reference_to_stranger?)
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless result
end
def mention_count
text.gsub(Account::MENTION_RE)&.count || 0
end
def mention_to_stranger?
@status.mentions.map(&:account).to_a.any? { |mentioned_account| mentioned_account.id != @status.account.id && !mentioned_account.following?(@status.account) } ||
(@status.thread.present? && @status.thread.account.id != @status.account.id && !@status.thread.account.following?(@status.account))
@ -101,9 +128,21 @@ class UpdateStatusService < BaseService
end
def referred_statuses
return [] unless @options[:text]
return [] unless text
ProcessReferencesService.extract_uris(@options[:text]).filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) }
reference_urls.filter_map { |uri| ActivityPub::TagManager.instance.local_uri?(uri) && ActivityPub::TagManager.instance.uri_to_resource(uri, Status, url: true) }
end
def quote_url
ProcessReferencesService.extract_quote(text)
end
def reference_urls
@reference_urls ||= ProcessReferencesService.extract_uris(text) || []
end
def text
@options.key?(:text) ? (@options[:text] || '') : @status.text
end
def validate_media!

View file

@ -5,6 +5,7 @@ class VoteService < BaseService
include Payloadable
include Redisable
include Lockable
include NgRuleHelper
def call(account, poll, choices)
return if choices.empty?
@ -16,6 +17,8 @@ class VoteService < BaseService
@choices = choices
@votes = []
raise Mastodon::ValidationError, I18n.t('statuses.violate_rules') unless check_invalid_reaction_for_ng_rule! @account, reaction_type: 'vote', recipient: @poll.status.account, target_status: @poll.status
already_voted = true
with_redis_lock("vote:#{@poll.id}:#{@account.id}") do

View file

@ -0,0 +1,42 @@
.batch-table__row
%label.batch-table__row__select.batch-checkbox
-# = f.check_box :history_ids, { multiple: true, include_hidden: false }, history.id
.batch-table__row__content
- if history.hidden
.simple_form
%p.hint= t('admin.ng_rule_histories.hidden')
- else
.status__content><
= html_aware_format(history.text, history.local)
.detailed-status__meta
= t("admin.ng_rule_histories.reason_actions.#{history.reason_action}")
- if history.data.present? && !history.hidden
- if history.data['media_count'].present? && history.data['media_count'].positive?
·
= t('admin.ng_rule_histories.data.media_count', count: history.data['media_count'])
- if history.data['poll_count'].present? && history.data['poll_count'].positive?
·
= t('admin.ng_rule_histories.data.poll_count', count: history.data['poll_count'])
%br/
- if history.account.present?
- if history.hidden
- if history.account.local?
= t('admin.ng_rule_histories.from_local_user')
- else
= history.account.domain
·
- else
= link_to t('admin.ng_rule_histories.moderate_account'), admin_account_path(history.account.id)
·
%time.formatted{ datetime: history.created_at.iso8601, title: l(history.created_at) }= l(history.created_at)
- if history.uri.present? && !history.hidden
·
- if history.data.present? && history.data['url'].present?
= link_to history.uri, history.data['url'] || history.uri, target: '_blank', rel: 'noopener'
- else
= link_to history.uri, target: '_blank', rel: 'noopener'

View file

@ -0,0 +1,25 @@
- content_for :page_title do
= t('admin.ng_rule_histories.title', title: @ng_rule.title)
.filters
.back-link
= link_to edit_admin_ng_rule_path(id: @ng_rule.id) do
= fa_icon 'chevron-left fw'
= t('admin.ng_rule_histories.back_to_ng_rule')
= link_to admin_ng_rules_path do
= fa_icon 'chevron-left fw'
= t('admin.ng_rule_histories.back_to_ng_rules')
%hr.spacer/
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__body
- if @histories.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'admin/ng_rule_histories/history', collection: @histories
= paginate @histories

View file

@ -0,0 +1,25 @@
.filters-list__item{ class: [(ng_rule.expired? || !ng_rule.available) && 'expired'] }
= link_to edit_admin_ng_rule_path(ng_rule), class: 'filters-list__item__title' do
= ng_rule.title.presence || "(#{t('admin.ng_rules.index.empty_title')})"
- if ng_rule.expires?
.expiration{ title: t('filters.index.expires_on', date: l(ng_rule.expires_at)) }
- if ng_rule.expired?
= t('invites.expired')
- else
= t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(ng_rule.expires_at))
- elsif !ng_rule.available
.expiration
= t('admin.ng_rules.index.disabled')
.filters-list__item__permissions
%ul.permissions-list
.announcements-list__item__action-bar
.announcements-list__item__meta
= link_to t('admin.ng_rules.index.hit_count', count: ng_rule.hit_count), admin_ng_rule_history_path(ng_rule)
%div
= table_link_to 'pencil', t('admin.ng_rules.index.edit.title'), edit_admin_ng_rule_path(ng_rule)
= table_link_to 'files-o', t('admin.ng_rules.copy'), duplicate_admin_ng_rule_path(ng_rule), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
= table_link_to 'times', t('admin.ng_rules.index.delete'), admin_ng_rule_path(ng_rule), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

View file

@ -0,0 +1,123 @@
.fields-group
%p= t('admin.ng_rules.edit.helps.generic')
.fields-group
%p
= t('admin.ng_rules.edit.helps.textarea_html')
= link_to t('admin.ng_rules.rubular'), 'https://rubular.com/', target: '_blank', rel: 'noopener'
.fields-group
%p= t('admin.ng_rules.edit.helps.threshold_html')
- if @ng_rule.id.present?
%p.hint= link_to t('admin.ng_rules.edit.history'), admin_ng_rule_history_path(id: @ng_rule.id)
%hr.spacer/
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :title, as: :string, wrapper: :with_label, hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week, 2.weeks, 1.month, 3.months].map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt')
%h4= t('admin.ng_rules.edit.headers.account')
%p.lead= t('admin.ng_rules.edit.summary.account')
.fields-group
= f.input :account_allow_followed_by_local, as: :boolean, wrapper: :with_label, hint: t('admin.ng_rules.account_allow_followed_by_local_hint'), label: t('admin.ng_rules.account_allow_followed_by_local')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_domain, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_domain'), hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_include_local, as: :boolean, wrapper: :with_label, label: t('admin.ng_rules.account_include_local'), hint: false
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_username, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_username'), hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_display_name, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_display_name'), hint: false
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_field_name, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_field_name'), hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_field_value, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_field_value'), hint: false
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_note, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.account_note'), hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :account_avatar_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.account_avatar_state')
= f.input :account_header_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.account_header_state')
%h4= t('admin.ng_rules.edit.headers.status')
%p.lead= t('admin.ng_rules.edit.summary.status')
.fields-group
= f.input :status_allow_follower_mention, as: :boolean, wrapper: :with_label, hint: t('admin.ng_rules.status_allow_follower_mention_hint'), label: t('admin.ng_rules.status_allow_follower_mention')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_text, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.status_text'), hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_spoiler_text, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.status_spoiler_text'), hint: false
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_tag, as: :text, input_html: { rows: 4 }, wrapper: :with_label, label: t('admin.ng_rules.status_tag'), hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_sensitive_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_sensitive_state')
= f.input :status_cw_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_cw_state')
.fields-group
= f.input :status_visibility, wrapper: :with_block_label, collection: Status.all_visibilities, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("statuses.visibilities.#{context}") }, include_blank: false, label: t('admin.ng_rules.status_visibility')
.fields-group
= f.input :status_searchability, wrapper: :with_block_label, collection: Status.all_searchabilities + %w(unset), as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("statuses.searchabilities.#{context}") }, include_blank: false, label: t('admin.ng_rules.status_searchability')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_media_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_media_state')
= f.input :status_media_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_media_threshold')
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_poll_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_poll_state')
= f.input :status_poll_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_poll_threshold')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_quote_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_quote_state')
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_reply_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_reply_state')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_mention_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_mention_state')
= f.input :status_mention_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_mention_threshold')
.fields-row__column.fields-row__column-6.fields-group
= f.input :status_reference_state, wrapper: :with_label, collection: %i(optional needed no_needed), include_blank: false, label_method: ->(i) { I18n.t("admin.ng_rules.states.#{i}") }, label: t('admin.ng_rules.status_reference_state')
= f.input :status_reference_threshold, as: :string, wrapper: :with_label, hint: false, label: t('admin.ng_rules.status_reference_threshold')
%h4= t('admin.ng_rules.edit.headers.reaction')
%p.lead= t('admin.ng_rules.edit.summary.reaction')
.fields-row
= f.input :reaction_allow_follower, wrapper: :with_label, hint: t('admin.ng_rules.reaction_allow_follower_hint'), label: t('admin.ng_rules.reaction_allow_follower')
.fields-group
= f.input :reaction_type, wrapper: :with_block_label, collection: %i(favourite emoji_reaction reblog follow vote), as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("admin.ng_rules.reaction_types.#{context}") }, include_blank: false, label: t('admin.ng_rules.reaction_type')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :emoji_reaction_name, as: :text, input_html: { rows: 4 }, wrapper: :with_label, hint: false, label: t('admin.ng_rules.emoji_reaction_name')
.fields-row__column.fields-row__column-6.fields-group
= f.input :emoji_reaction_origin_domain, as: :text, input_html: { rows: 4 }, wrapper: :with_label, hint: t('admin.ng_rules.emoji_reaction_origin_domain_hint'), label: t('admin.ng_rules.emoji_reaction_origin_domain')
%h4= t('admin.ng_rules.edit.headers.options')
.fields-group
= f.input :available, wrapper: :with_label, label: t('admin.ng_rules.available'), hint: false
.fields-group
= f.input :record_history_also_local, wrapper: :with_label, label: t('admin.ng_rules.record_history_also_local'), hint: false

View file

@ -0,0 +1,9 @@
- content_for :page_title do
= t('admin.ng_rules.edit.title')
= simple_form_for @ng_rule, url: admin_ng_rule_path(@ng_rule), method: :put do |f|
= render 'shared/error_messages', object: @ng_rule
= render 'ng_rule_fields', f: f
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View file

@ -0,0 +1,14 @@
- content_for :page_title do
= t('admin.ng_rules.index.title')
- content_for :heading_actions do
= link_to t('admin.ng_rules.new.title'), new_admin_ng_rule_path, class: 'button'
.simple_form
%p.lead= t('admin.ng_rules.index.preamble')
- if @ng_rules.empty?
.muted-hint.center-text= t 'admin.ng_rules.index.empty'
- else
.applications-list
= render partial: 'ng_rule', collection: @ng_rules

View file

@ -0,0 +1,8 @@
- content_for :page_title do
= t('admin.ng_rules.new.title')
= simple_form_for @ng_rule, url: admin_ng_rules_path do |f|
= render 'ng_rule_fields', f: f
.actions
= f.button :button, t('admin.ng_rules.new.save'), type: :submit

View file

@ -7,6 +7,8 @@
= simple_form_for @admin_settings, url: admin_ng_words_path, html: { method: :post } do |f|
= render 'shared/error_messages', object: @admin_settings
%p.lead= t('admin.ng_words.preamble')
%p.hint
= t 'admin.ng_words.history_hint'
= link_to t('admin.ngword_histories.title'), admin_ngword_histories_path
@ -14,9 +16,6 @@
.fields-group
= f.input :ng_words_for_stranger_mention, wrapper: :with_label, as: :text, input_html: { rows: 10 }, label: t('admin.ng_words.keywords_for_stranger_mention'), hint: t('admin.ng_words.keywords_for_stranger_mention_hint')
.fields-group
= f.input :stranger_mention_from_local_ng, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.stranger_mention_from_local_ng'), hint: t('admin.ng_words.stranger_mention_from_local_ng_hint')
.fields-group
= f.input :ng_words, wrapper: :with_label, as: :text, input_html: { rows: 10 }, label: t('admin.ng_words.keywords'), hint: t('admin.ng_words.keywords_hint')
@ -29,21 +28,28 @@
.fields-group
= f.input :post_mentions_max, wrapper: :with_label, as: :integer, label: t('admin.ng_words.post_mentions_max')
.fields-group
= f.input :hide_local_users_for_anonymous, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hide_local_users_for_anonymous')
.fields-group
= f.input :block_unfollow_account_mention, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.block_unfollow_account_mention')
%p.hint
= t 'admin.ng_words.remote_approval_hint'
%h4= t('admin.ng_words.white_list')
%p.lead
= t('admin.ng_words.white_list_hint')
= link_to t('admin.ng_words.remote_approval_list'), admin_accounts_path(status: 'remote_pending', origin: 'remote')
.fields-group
= f.input :hold_remote_new_accounts, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hold_remote_new_accounts')
= f.input :hold_remote_new_accounts, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hold_remote_new_accounts'), hint: t('admin.ng_words.remote_approval_hint')
.fields-group
= f.input :permit_new_account_domains, wrapper: :with_label, as: :text, kmyblue: true, input_html: { rows: 6 }, label: t('admin.ng_words.permit_new_account_domains')
%h4= t('admin.ng_words.deprecated')
%p.hint= t('admin.ng_words.deprecated_hint')
.fields-group
= f.input :stranger_mention_from_local_ng, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.stranger_mention_from_local_ng'), hint: t('admin.ng_words.stranger_mention_from_local_ng_hint')
.fields-group
= f.input :hide_local_users_for_anonymous, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.hide_local_users_for_anonymous'), hint: t('admin.ng_words.hide_local_users_for_anonymous_hint')
.fields-group
= f.input :block_unfollow_account_mention, wrapper: :with_label, as: :boolean, label: t('admin.ng_words.block_unfollow_account_mention'), hint: t('admin.ng_words.block_unfollow_account_mention_hint')
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View file

@ -4,16 +4,16 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
%p.hint= t 'admin.sensitive_words.hint'
= simple_form_for @admin_settings, url: admin_sensitive_words_path, html: { method: :post } do |f|
= render 'shared/error_messages', object: @admin_settings
.fields-group
= f.input :sensitive_words_for_full, wrapper: :with_label, as: :text, input_html: { rows: 12 }, label: t('admin.sensitive_words.keywords_for_all'), hint: t('admin.sensitive_words.keywords_for_all_hint')
%p.lead= t 'admin.sensitive_words.hint'
.fields-group
= f.input :sensitive_words, wrapper: :with_label, as: :text, input_html: { rows: 12 }, label: t('admin.sensitive_words.keywords'), hint: t('admin.sensitive_words.keywords_hint')
= f.input :sensitive_words_for_full, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords_for_all'), hint: t('admin.sensitive_words.keywords_for_all_hint')
.fields-group
= f.input :sensitive_words, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords'), hint: t('admin.sensitive_words.keywords_hint')
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View file

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