* Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
		
			
				
	
	
		
			303 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			303 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
# frozen_string_literal: true
 | 
						|
 | 
						|
module AccountInteractions
 | 
						|
  extend ActiveSupport::Concern
 | 
						|
 | 
						|
  class_methods do
 | 
						|
    def following_map(target_account_ids, account_id)
 | 
						|
      Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping|
 | 
						|
        mapping[follow.target_account_id] = {
 | 
						|
          reblogs: follow.show_reblogs?,
 | 
						|
          notify: follow.notify?,
 | 
						|
        }
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    def followed_by_map(target_account_ids, account_id)
 | 
						|
      follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
 | 
						|
    end
 | 
						|
 | 
						|
    def blocking_map(target_account_ids, account_id)
 | 
						|
      follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
 | 
						|
    end
 | 
						|
 | 
						|
    def blocked_by_map(target_account_ids, account_id)
 | 
						|
      follow_mapping(Block.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
 | 
						|
    end
 | 
						|
 | 
						|
    def muting_map(target_account_ids, account_id)
 | 
						|
      Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
 | 
						|
        mapping[mute.target_account_id] = {
 | 
						|
          notifications: mute.hide_notifications?,
 | 
						|
        }
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    def requested_map(target_account_ids, account_id)
 | 
						|
      FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping|
 | 
						|
        mapping[follow_request.target_account_id] = {
 | 
						|
          reblogs: follow_request.show_reblogs?,
 | 
						|
          notify: follow_request.notify?,
 | 
						|
        }
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    def endorsed_map(target_account_ids, account_id)
 | 
						|
      follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
 | 
						|
    end
 | 
						|
 | 
						|
    def account_note_map(target_account_ids, account_id)
 | 
						|
      AccountNote.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |note, mapping|
 | 
						|
        mapping[note.target_account_id] = {
 | 
						|
          comment: note.comment,
 | 
						|
        }
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    def domain_blocking_map(target_account_ids, account_id)
 | 
						|
      accounts_map    = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
 | 
						|
      blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
 | 
						|
      accounts_map.reduce({}) { |h, (id, domain)| h.merge(id => blocked_domains[domain]) }
 | 
						|
    end
 | 
						|
 | 
						|
    def domain_blocking_map_by_domain(target_domains, account_id)
 | 
						|
      follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
 | 
						|
    end
 | 
						|
 | 
						|
    private
 | 
						|
 | 
						|
    def follow_mapping(query, field)
 | 
						|
      query.pluck(field).index_with(true)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  included do
 | 
						|
    # Follow relations
 | 
						|
    has_many :follow_requests, dependent: :destroy
 | 
						|
 | 
						|
    has_many :active_relationships,  class_name: 'Follow', foreign_key: 'account_id',        dependent: :destroy
 | 
						|
    has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
 | 
						|
 | 
						|
    has_many :following, -> { order('follows.id desc') }, through: :active_relationships,  source: :target_account
 | 
						|
    has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
 | 
						|
 | 
						|
    # Account notes
 | 
						|
    has_many :account_notes, dependent: :destroy
 | 
						|
 | 
						|
    # Block relationships
 | 
						|
    has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
 | 
						|
    has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
 | 
						|
    has_many :blocked_by_relationships, class_name: 'Block', foreign_key: :target_account_id, dependent: :destroy
 | 
						|
    has_many :blocked_by, -> { order('blocks.id desc') }, through: :blocked_by_relationships, source: :account
 | 
						|
 | 
						|
    # Mute relationships
 | 
						|
    has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
 | 
						|
    has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
 | 
						|
    has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy
 | 
						|
    has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
 | 
						|
    has_many :conversation_mutes, dependent: :destroy
 | 
						|
    has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
 | 
						|
    has_many :announcement_mutes, dependent: :destroy
 | 
						|
  end
 | 
						|
 | 
						|
  def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false, bypass_limit: false)
 | 
						|
    rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
 | 
						|
                              .find_or_create_by!(target_account: other_account)
 | 
						|
 | 
						|
    rel.show_reblogs = reblogs unless reblogs.nil?
 | 
						|
    rel.notify       = notify  unless notify.nil?
 | 
						|
 | 
						|
    rel.save! if rel.changed?
 | 
						|
 | 
						|
    remove_potential_friendship(other_account)
 | 
						|
 | 
						|
    rel
 | 
						|
  end
 | 
						|
 | 
						|
  def request_follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false, bypass_limit: false)
 | 
						|
    rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
 | 
						|
                         .find_or_create_by!(target_account: other_account)
 | 
						|
 | 
						|
    rel.show_reblogs = reblogs unless reblogs.nil?
 | 
						|
    rel.notify       = notify  unless notify.nil?
 | 
						|
 | 
						|
    rel.save! if rel.changed?
 | 
						|
 | 
						|
    remove_potential_friendship(other_account)
 | 
						|
 | 
						|
    rel
 | 
						|
  end
 | 
						|
 | 
						|
  def block!(other_account, uri: nil)
 | 
						|
    remove_potential_friendship(other_account)
 | 
						|
    block_relationships.create_with(uri: uri)
 | 
						|
                       .find_or_create_by!(target_account: other_account)
 | 
						|
  end
 | 
						|
 | 
						|
  def mute!(other_account, notifications: nil, duration: 0)
 | 
						|
    notifications = true if notifications.nil?
 | 
						|
    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_initialize_by(target_account: other_account)
 | 
						|
    mute.expires_in = duration.zero? ? nil : duration
 | 
						|
    mute.save!
 | 
						|
 | 
						|
    remove_potential_friendship(other_account)
 | 
						|
 | 
						|
    # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
 | 
						|
    if mute.hide_notifications? != notifications
 | 
						|
      mute.update!(hide_notifications: notifications)
 | 
						|
    end
 | 
						|
 | 
						|
    mute
 | 
						|
  end
 | 
						|
 | 
						|
  def mute_conversation!(conversation)
 | 
						|
    conversation_mutes.find_or_create_by!(conversation: conversation)
 | 
						|
  end
 | 
						|
 | 
						|
  def block_domain!(other_domain)
 | 
						|
    domain_blocks.find_or_create_by!(domain: other_domain)
 | 
						|
  end
 | 
						|
 | 
						|
  def unfollow!(other_account)
 | 
						|
    follow = active_relationships.find_by(target_account: other_account)
 | 
						|
    follow&.destroy
 | 
						|
  end
 | 
						|
 | 
						|
  def unblock!(other_account)
 | 
						|
    block = block_relationships.find_by(target_account: other_account)
 | 
						|
    block&.destroy
 | 
						|
  end
 | 
						|
 | 
						|
  def unmute!(other_account)
 | 
						|
    mute = mute_relationships.find_by(target_account: other_account)
 | 
						|
    mute&.destroy
 | 
						|
  end
 | 
						|
 | 
						|
  def unmute_conversation!(conversation)
 | 
						|
    mute = conversation_mutes.find_by(conversation: conversation)
 | 
						|
    mute&.destroy!
 | 
						|
  end
 | 
						|
 | 
						|
  def unblock_domain!(other_domain)
 | 
						|
    block = domain_blocks.find_by(domain: other_domain)
 | 
						|
    block&.destroy
 | 
						|
  end
 | 
						|
 | 
						|
  def following?(other_account)
 | 
						|
    active_relationships.where(target_account: other_account).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def following_anyone?
 | 
						|
    active_relationships.exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def not_following_anyone?
 | 
						|
    !following_anyone?
 | 
						|
  end
 | 
						|
 | 
						|
  def followed_by?(other_account)
 | 
						|
    other_account.following?(self)
 | 
						|
  end
 | 
						|
 | 
						|
  def blocking?(other_account)
 | 
						|
    block_relationships.where(target_account: other_account).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def domain_blocking?(other_domain)
 | 
						|
    domain_blocks.where(domain: other_domain).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def muting?(other_account)
 | 
						|
    mute_relationships.where(target_account: other_account).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def muting_conversation?(conversation)
 | 
						|
    conversation_mutes.where(conversation: conversation).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def muting_notifications?(other_account)
 | 
						|
    mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def muting_reblogs?(other_account)
 | 
						|
    active_relationships.where(target_account: other_account, show_reblogs: false).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def requested?(other_account)
 | 
						|
    follow_requests.where(target_account: other_account).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def favourited?(status)
 | 
						|
    status.proper.favourites.where(account: self).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def bookmarked?(status)
 | 
						|
    status.proper.bookmarks.where(account: self).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def reblogged?(status)
 | 
						|
    status.proper.reblogs.where(account: self).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def pinned?(status)
 | 
						|
    status_pins.where(status: status).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def endorsed?(account)
 | 
						|
    account_pins.where(target_account: account).exists?
 | 
						|
  end
 | 
						|
 | 
						|
  def status_matches_filters(status)
 | 
						|
    active_filters = CustomFilter.cached_filters_for(id)
 | 
						|
 | 
						|
    filter_matches = active_filters.filter_map do |filter, rules|
 | 
						|
      next if rules[:keywords].blank?
 | 
						|
 | 
						|
      match = rules[:keywords].match(status.proper.searchable_text)
 | 
						|
      FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
 | 
						|
    end
 | 
						|
 | 
						|
    filter_matches
 | 
						|
  end
 | 
						|
 | 
						|
  def followers_for_local_distribution
 | 
						|
    followers.local
 | 
						|
             .joins(:user)
 | 
						|
             .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
 | 
						|
  end
 | 
						|
 | 
						|
  def lists_for_local_distribution
 | 
						|
    lists.joins(account: :user)
 | 
						|
         .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
 | 
						|
  end
 | 
						|
 | 
						|
  def remote_followers_hash(url)
 | 
						|
    url_prefix = url[Account::URL_PREFIX_RE]
 | 
						|
    return if url_prefix.blank?
 | 
						|
 | 
						|
    Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}/") do
 | 
						|
      digest = "\x00" * 32
 | 
						|
      followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(url_prefix)}/%", false, true)).or(followers.where(uri: url_prefix)).pluck_each(:uri) do |uri|
 | 
						|
        Xorcist.xor!(digest, Digest::SHA256.digest(uri))
 | 
						|
      end
 | 
						|
      digest.unpack('H*')[0]
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def local_followers_hash
 | 
						|
    Rails.cache.fetch("followers_hash:#{id}:local") do
 | 
						|
      digest = "\x00" * 32
 | 
						|
      followers.where(domain: nil).pluck_each(:username) do |username|
 | 
						|
        Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username)))
 | 
						|
      end
 | 
						|
      digest.unpack('H*')[0]
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  private
 | 
						|
 | 
						|
  def remove_potential_friendship(other_account, mutual = false)
 | 
						|
    PotentialFriendshipTracker.remove(id, other_account.id)
 | 
						|
    PotentialFriendshipTracker.remove(other_account.id, id) if mutual
 | 
						|
  end
 | 
						|
end
 |