diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index ed98acce30..3b097a3478 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -52,11 +52,11 @@ class Api::V1::FiltersController < Api::BaseController end def resource_params - params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) + params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :whole_word, context: []) end def filter_params - resource_params.slice(:phrase, :expires_in, :irreversible, :context) + resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :context) end def keyword_params diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb index 2fcdeeae45..f3e9938d8c 100644 --- a/app/controllers/api/v2/filters_controller.rb +++ b/app/controllers/api/v2/filters_controller.rb @@ -43,6 +43,6 @@ class Api::V2::FiltersController < Api::BaseController end def resource_params - params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index bbe177ead1..b0b2168884 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -49,7 +49,7 @@ class FiltersController < ApplicationController end def resource_params - params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end def set_body_classes diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 912c0256fc..649e64e398 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -265,7 +265,7 @@ module AccountInteractions def status_matches_filters(status) active_filters = CustomFilter.cached_filters_for(id) - CustomFilter.apply_cached_filters(active_filters, status) + CustomFilter.apply_cached_filters(active_filters, status, following?(status.account)) end def followers_for_local_distribution diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 0f4fd78cbf..f431dc59ff 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -4,14 +4,16 @@ # # Table name: custom_filters # -# id :bigint(8) not null, primary key -# account_id :bigint(8) -# expires_at :datetime -# phrase :text default(""), not null -# context :string default([]), not null, is an Array -# created_at :datetime not null -# updated_at :datetime not null -# action :integer default("warn"), not null +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# action :integer default("warn"), not null +# exclude_follows :boolean default(FALSE), not null +# exclude_localusers :boolean default(FALSE), not null # class CustomFilter < ApplicationRecord @@ -94,8 +96,11 @@ class CustomFilter < ApplicationRecord active_filters.select { |custom_filter, _| !custom_filter.expired? } end - def self.apply_cached_filters(cached_filters, status) + def self.apply_cached_filters(cached_filters, status, following) cached_filters.filter_map do |filter, rules| + next if filter.exclude_follows && following + next if filter.exclude_localusers && status.account.local? + match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present? keyword_matches = [match.to_s] unless match.nil? diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb index 3158b3b79a..3a853daf30 100644 --- a/app/models/custom_filter_keyword.rb +++ b/app/models/custom_filter_keyword.rb @@ -7,7 +7,7 @@ # id :bigint(8) not null, primary key # custom_filter_id :bigint(8) not null # keyword :text default(""), not null -# whole_word :boolean default(TRUE), not null +# whole_word :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index e5ec67f8c0..5ebb9aa25a 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -7,6 +7,8 @@ class StatusRelationshipsPresenter :bookmarks_map, :filters_map, :emoji_reactions_map def initialize(statuses, current_account_id = nil, **options) + @current_account_id = current_account_id + if current_account_id.nil? @reblogs_map = {} @favourites_map = {} @@ -37,7 +39,7 @@ class StatusRelationshipsPresenter active_filters = CustomFilter.cached_filters_for(current_account_id) @filters_map = statuses.each_with_object({}) do |status, h| - filter_matches = CustomFilter.apply_cached_filters(active_filters, status) + filter_matches = CustomFilter.apply_cached_filters(active_filters, status, following?(status.account_id)) unless filter_matches.empty? h[status.id] = filter_matches @@ -45,4 +47,14 @@ class StatusRelationshipsPresenter end end end + + def following?(other_account_id) + return false if @current_account_id.nil? + + @account ||= Account.find(@current_account_id) + return false unless @account + + @following_map ||= @account.following.pluck(:id) + @following_map.include?(other_account_id) + end end diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb index 8816dd8076..1b5d70dffd 100644 --- a/app/serializers/rest/filter_serializer.rb +++ b/app/serializers/rest/filter_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::FilterSerializer < ActiveModel::Serializer - attributes :id, :title, :context, :expires_at, :filter_action + attributes :id, :title, :exclude_follows, :exclude_localusers, :context, :expires_at, :filter_action has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested? has_many :statuses, serializer: REST::FilterStatusSerializer, if: :rules_requested? diff --git a/app/serializers/rest/v1/filter_serializer.rb b/app/serializers/rest/v1/filter_serializer.rb index 455f17efdb..ef77e3ca82 100644 --- a/app/serializers/rest/v1/filter_serializer.rb +++ b/app/serializers/rest/v1/filter_serializer.rb @@ -2,7 +2,7 @@ class REST::V1::FilterSerializer < ActiveModel::Serializer attributes :id, :phrase, :context, :whole_word, :expires_at, - :irreversible + :irreversible, :exclude_follows, :exclude_localusers delegate :context, :expires_at, to: :custom_filter @@ -18,6 +18,10 @@ class REST::V1::FilterSerializer < ActiveModel::Serializer custom_filter.irreversible? end + delegate :exclude_follows, to: :custom_filter + + delegate :exclude_localusers, to: :custom_filter + private def custom_filter diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml index a554b55ff6..383ec46124 100644 --- a/app/views/filters/_filter_fields.html.haml +++ b/app/views/filters/_filter_fields.html.haml @@ -12,6 +12,10 @@ .fields-group = f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true +.fields-group + = f.input :exclude_follows, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.exclude_follows') + = f.input :exclude_localusers, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.exclude_localusers') + %hr.spacer/ - unless f.object.statuses.empty? diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 1cf2f76d53..38ba894e07 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -260,6 +260,9 @@ en: actions: hide: Hide completely warn: Hide with a warning + options: + exclude_follows: Exclude following users + exclude_localusers: Exclude local users form_admin_settings: activity_api_enabled: Publish aggregate statistics about user activity in the API backups_retention_period: User archive retention period diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index d58f52b8f2..2934c0550d 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -268,6 +268,9 @@ ja: actions: hide: 完全に隠す warn: 警告付きで隠す + options: + exclude_follows: フォロー中のユーザーをフィルターの対象にしない + exclude_localusers: ローカルユーザーをフィルターの対象にしない form_admin_settings: activity_api_enabled: APIでユーザーアクティビティに関する集計統計を公開する backups_retention_period: ユーザーアーカイブの保持期間 diff --git a/db/migrate/20230714004824_add_exclude_options_to_filters.rb b/db/migrate/20230714004824_add_exclude_options_to_filters.rb new file mode 100644 index 0000000000..07ca56eb06 --- /dev/null +++ b/db/migrate/20230714004824_add_exclude_options_to_filters.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddExcludeOptionsToFilters < ActiveRecord::Migration[6.1] + def change + safety_assured do + add_column :custom_filters, :exclude_follows, :boolean, null: false, default: false + add_column :custom_filters, :exclude_localusers, :boolean, null: false, default: false + change_column_default :custom_filter_keywords, :whole_word, from: true, to: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 60f032436e..61db4f5a5c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_07_06_031715) do +ActiveRecord::Schema.define(version: 2023_07_14_004824) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -446,7 +446,7 @@ ActiveRecord::Schema.define(version: 2023_07_06_031715) do create_table "custom_filter_keywords", force: :cascade do |t| t.bigint "custom_filter_id", null: false t.text "keyword", default: "", null: false - t.boolean "whole_word", default: true, null: false + t.boolean "whole_word", default: false, null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id" @@ -469,6 +469,8 @@ ActiveRecord::Schema.define(version: 2023_07_06_031715) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "action", default: 0, null: false + t.boolean "exclude_follows", default: false, null: false + t.boolean "exclude_localusers", default: false, null: false t.index ["account_id"], name: "index_custom_filters_on_account_id" end @@ -1228,6 +1230,7 @@ ActiveRecord::Schema.define(version: 2023_07_06_031715) do t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, opclass: :text_pattern_ops, where: "(reset_password_token IS NOT NULL)" t.index ["role_id"], name: "index_users_on_role_id", where: "(role_id IS NOT NULL)" + t.index ["unconfirmed_email"], name: "index_users_on_unconfirmed_email", where: "(unconfirmed_email IS NOT NULL)" end create_table "web_push_subscriptions", force: :cascade do |t| diff --git a/streaming/index.js b/streaming/index.js index 279ebbad83..a33608826e 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -671,7 +671,13 @@ const startServer = async () => { } if (!unpackedPayload.filtered && !req.cachedFilters) { - queries.push(client.query('SELECT filter.id AS id, filter.phrase AS title, filter.context AS context, filter.expires_at AS expires_at, filter.action AS filter_action, keyword.keyword AS keyword, keyword.whole_word AS whole_word FROM custom_filter_keywords keyword JOIN custom_filters filter ON keyword.custom_filter_id = filter.id WHERE filter.account_id = $1 AND (filter.expires_at IS NULL OR filter.expires_at > NOW())', [req.accountId])); + queries.push(client.query('SELECT filter.id AS id, filter.phrase AS title, filter.context AS context, filter.expires_at AS expires_at, filter.action AS filter_action, keyword.keyword AS keyword, keyword.whole_word AS whole_word, filter.exclude_follows AS exclude_follows, filter.exclude_localusers AS exclude_localusers FROM custom_filter_keywords keyword JOIN custom_filters filter ON keyword.custom_filter_id = filter.id WHERE filter.account_id = $1 AND (filter.expires_at IS NULL OR filter.expires_at > NOW())', [req.accountId])); + } + if (!unpackedPayload.filtered) { + queries.push(client.query(`SELECT accounts.domain AS domain + FROM follows + JOIN accounts ON follows.target_account_id = accounts.id + WHERE (account_id = $1 AND target_account_id = $2)`, [req.accountId, unpackedPayload.account.id])); } Promise.all(queries).then(values => { @@ -681,6 +687,8 @@ const startServer = async () => { return; } + const following = !unpackedPayload.filtered && values[values.length - 1].rows.length > 0; + if (!unpackedPayload.filtered && !req.cachedFilters) { const filterRows = values[accountDomain ? 2 : 1].rows; @@ -697,6 +705,8 @@ const startServer = async () => { context: row.context, expires_at: row.expires_at, filter_action: ['warn', 'hide'][row.filter_action], + excludeFollows: row.exclude_follows, + excludeLocalusers: row.exclude_localusers, }, }; } @@ -732,7 +742,7 @@ const startServer = async () => { const now = new Date(); payload.filtered = []; Object.values(req.cachedFilters).forEach((cachedFilter) => { - if ((cachedFilter.expires_at === null || cachedFilter.expires_at > now)) { + if ((cachedFilter.expires_at === null || cachedFilter.expires_at > now) && (!cachedFilter.repr.excludeFollows || !following) && (!cachedFilter.repr.excludeLocalusers || accountDomain)) { const keyword_matches = searchIndex.match(cachedFilter.regexp); if (keyword_matches) { payload.filtered.push({