about summary refs log tree commit diff
path: root/app/models/custom_filter.rb
blob: 5a4a974be432f2e7c5cf45e1e41fa391ca4473fd (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# frozen_string_literal: true
# == Schema Information
#
# 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
#

class CustomFilter < ApplicationRecord
  self.ignored_columns = %w(whole_word irreversible)

  alias_attribute :title, :phrase
  alias_attribute :filter_action, :action

  VALID_CONTEXTS = %w(
    home
    notifications
    public
    thread
    account
  ).freeze

  include Expireable
  include Redisable

  enum action: [:warn, :hide], _suffix: :action

  belongs_to :account
  has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
  has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
  accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true

  validates :title, :context, presence: true
  validate :context_must_be_valid

  before_validation :clean_up_contexts

  before_save :prepare_cache_invalidation!
  before_destroy :prepare_cache_invalidation!
  after_commit :invalidate_cache!

  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].find { |expires_in| expires_in.from_now >= expires_at }
  end

  def irreversible=(value)
    self.action = ActiveModel::Type::Boolean.new.cast(value) ? :hide : :warn
  end

  def irreversible?
    hide_action?
  end

  def self.cached_filters_for(account_id)
    active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
      filters_hash = {}

      scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
      scope.to_a.group_by(&:custom_filter).each do |filter, keywords|
        keywords.map! do |keyword|
          if keyword.whole_word
            sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
            eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : ''

            /(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/
          else
            /#{Regexp.escape(keyword.keyword)}/i
          end
        end

        filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter }
      end.to_h

      scope = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
      scope.to_a.group_by(&:custom_filter).each do |filter, statuses|
        filters_hash[filter.id] ||= { filter: filter }
        filters_hash[filter.id].merge!(status_ids: statuses.map(&:status_id))
      end

      filters_hash.values.map { |cache| [cache.delete(:filter), cache] }
    end.to_a

    active_filters.select { |custom_filter, _| !custom_filter.expired? }
  end

  def self.apply_cached_filters(cached_filters, status)
    cached_filters.filter_map do |filter, rules|
      match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present?
      keyword_matches = [match.to_s] unless match.nil?

      status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?

      next if keyword_matches.blank? && status_matches.blank?
      FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
    end
  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("filters:v3:#{account_id}")
    redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
    redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed))
  end

  private

  def clean_up_contexts
    self.context = Array(context).map(&:strip).filter_map(&:presence)
  end

  def context_must_be_valid
    errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
  end
end